Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion crates/end_io/src/aic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ use crate::error::map_aic_build_error;
use crate::schema::{AicToml, PowerToml};
use crate::{Error, Result};
use end_model::{
AicInputs, Catalog, ItemPosF64Map, ItemU32Map, OutpostInput, PosF64, PowerConfig, Stage2Weights,
AicInputs, Catalog, FacilityU32Map, ItemPosF64Map, ItemU32Map, OutpostInput, PosF64,
PowerConfig, Stage2Weights,
};
use generativity::{Guard, make_guard};
use std::collections::BTreeMap;
Expand Down Expand Up @@ -149,13 +150,31 @@ fn resolve_aic<'cid, 'sid>(
external_consumption_per_min.insert(item, value);
}

let facility_machines_max_span = raw.facility_machines_max.span();
let raw_facility_machines_max = raw.facility_machines_max.into_inner();
let mut facility_machines_max = FacilityU32Map::with_capacity(raw_facility_machines_max.len());
for (facility_key, value) in raw_facility_machines_max {
let facility_key = facility_key.into_inner();
let value = value.into_inner();
let facility = catalog
.facility_id(facility_key.as_str())
.ok_or_else(|| Error::UnknownFacility {
path: path.clone(),
key: facility_key.into(),
span: Some(facility_machines_max_span.clone()),
src: Some(Arc::clone(&src)),
})?;
facility_machines_max.insert(facility, value);
}

let mut builder = AicInputs::builder(
guard,
power_config,
supply_per_min,
external_consumption_per_min,
)
.region(region)
.facility_machines_max(facility_machines_max)
.stage2_weights(stage2_weights);

let mut outpost_spans = BTreeMap::new();
Expand Down
6 changes: 6 additions & 0 deletions crates/end_io/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@
pub(crate) supply_per_min: Spanned<BTreeMap<KeyToml, PositiveF64Toml>>,
#[serde(default = "default_empty_spanned_item_positive_f64_map")]
pub(crate) external_consumption_per_min: Spanned<BTreeMap<KeyToml, PositiveF64Toml>>,
#[serde(default = "default_empty_spanned_key_non_negative_u32_map")]
pub(crate) facility_machines_max: Spanned<BTreeMap<KeyToml, NonNegativeU32Toml>>,
#[serde(default)]
pub(crate) outposts: Box<[Spanned<OutpostToml>]>,
}
Expand Down Expand Up @@ -286,10 +288,10 @@
}

#[derive(Debug, Clone, Copy)]
pub(crate) struct PositiveU32Toml(NonZeroU32);

Check warning on line 291 in crates/end_io/src/schema.rs

View workflow job for this annotation

GitHub Actions / done

struct `PositiveU32Toml` is never constructed

Check warning on line 291 in crates/end_io/src/schema.rs

View workflow job for this annotation

GitHub Actions / done

struct `PositiveU32Toml` is never constructed

Check warning on line 291 in crates/end_io/src/schema.rs

View workflow job for this annotation

GitHub Actions / Tauri Desktop macos-latest

struct `PositiveU32Toml` is never constructed

Check warning on line 291 in crates/end_io/src/schema.rs

View workflow job for this annotation

GitHub Actions / Tauri Desktop windows-latest

struct `PositiveU32Toml` is never constructed

Check warning on line 291 in crates/end_io/src/schema.rs

View workflow job for this annotation

GitHub Actions / Tauri Desktop ubuntu-22.04

struct `PositiveU32Toml` is never constructed

impl PositiveU32Toml {
pub(crate) fn into_inner(self) -> NonZeroU32 {

Check warning on line 294 in crates/end_io/src/schema.rs

View workflow job for this annotation

GitHub Actions / done

method `into_inner` is never used

Check warning on line 294 in crates/end_io/src/schema.rs

View workflow job for this annotation

GitHub Actions / done

method `into_inner` is never used

Check warning on line 294 in crates/end_io/src/schema.rs

View workflow job for this annotation

GitHub Actions / Tauri Desktop macos-latest

method `into_inner` is never used

Check warning on line 294 in crates/end_io/src/schema.rs

View workflow job for this annotation

GitHub Actions / Tauri Desktop windows-latest

method `into_inner` is never used

Check warning on line 294 in crates/end_io/src/schema.rs

View workflow job for this annotation

GitHub Actions / Tauri Desktop ubuntu-22.04

method `into_inner` is never used
self.0
}
}
Expand Down Expand Up @@ -544,3 +546,7 @@
fn default_empty_spanned_item_positive_f64_map() -> Spanned<BTreeMap<KeyToml, PositiveF64Toml>> {
Spanned::new(0..0, BTreeMap::new())
}

fn default_empty_spanned_key_non_negative_u32_map() -> Spanned<BTreeMap<KeyToml, NonNegativeU32Toml>> {
Spanned::new(0..0, BTreeMap::new())
}
4 changes: 2 additions & 2 deletions crates/end_model/src/aic_input/mod.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
mod model;

pub use model::{
AicBuildError, AicInputs, AicInputsBuilder, ItemNonZeroU32Map, ItemPosF64Map, ItemU32Map, OutpostId,
OutpostInput, PowerConfig, Region, Stage2Weights,
AicBuildError, AicInputs, AicInputsBuilder, FacilityU32Map, ItemNonZeroU32Map, ItemPosF64Map,
ItemU32Map, OutpostId, OutpostInput, PowerConfig, Region, Stage2Weights,
};

#[cfg(test)]
Expand Down
13 changes: 11 additions & 2 deletions crates/end_model/src/aic_input/model/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ use std::collections::HashSet;
use generativity::Guard;

use super::{
AicBuildError, AicInputs, ItemPosF64Map, OutpostId, OutpostInput, PowerConfig, Region,
Stage2Weights,
AicBuildError, AicInputs, FacilityU32Map, ItemPosF64Map, OutpostId, OutpostInput, PowerConfig,
Region, Stage2Weights,
};

#[derive(Debug)]
pub struct AicInputsBuilder<'cid, 'sid> {
region: Region,
supply_per_min: ItemPosF64Map<'cid>,
external_consumption_per_min: ItemPosF64Map<'cid>,
facility_machines_max: FacilityU32Map<'cid>,
outposts: Vec<OutpostInput<'cid>>,
outpost_keys: HashSet<crate::Key>,
power_config: PowerConfig,
Expand All @@ -30,6 +31,7 @@ impl<'cid, 'sid> AicInputs<'cid, 'sid> {
region: Region::FourthValley,
supply_per_min,
external_consumption_per_min,
facility_machines_max: FacilityU32Map::default(),
outposts: Vec::new(),
outpost_keys: HashSet::new(),
power_config,
Expand All @@ -45,6 +47,11 @@ impl<'cid, 'sid> AicInputsBuilder<'cid, 'sid> {
self
}

pub fn facility_machines_max(mut self, facility_machines_max: FacilityU32Map<'cid>) -> Self {
self.facility_machines_max = facility_machines_max;
self
}

pub fn stage2_weights(mut self, stage2_weights: Stage2Weights) -> Self {
self.stage2_weights = stage2_weights;
self
Expand All @@ -68,6 +75,7 @@ impl<'cid, 'sid> AicInputsBuilder<'cid, 'sid> {
region,
supply_per_min,
external_consumption_per_min,
facility_machines_max,
outposts,
outpost_keys: _,
power_config,
Expand All @@ -79,6 +87,7 @@ impl<'cid, 'sid> AicInputsBuilder<'cid, 'sid> {
region,
supply_per_min,
external_consumption_per_min,
facility_machines_max,
outposts: outposts.into_boxed_slice(),
power_config,
stage2_weights,
Expand Down
8 changes: 6 additions & 2 deletions crates/end_model/src/aic_input/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ mod types;

pub use builder::AicInputsBuilder;
pub use types::{
AicBuildError, AicInputs, ItemNonZeroU32Map, ItemPosF64Map, ItemU32Map, OutpostId, OutpostInput, PowerConfig,
Region, Stage2Weights,
AicBuildError, AicInputs, FacilityU32Map, ItemNonZeroU32Map, ItemPosF64Map, ItemU32Map,
OutpostId, OutpostInput, PowerConfig, Region, Stage2Weights,
};

impl<'cid, 'sid> AicInputs<'cid, 'sid> {
Expand All @@ -28,6 +28,10 @@ impl<'cid, 'sid> AicInputs<'cid, 'sid> {
&self.external_consumption_per_min
}

pub fn facility_machines_max(&self) -> &FacilityU32Map<'cid> {
&self.facility_machines_max
}

pub fn outposts(&self) -> &[OutpostInput<'cid>] {
&self.outposts
}
Expand Down
73 changes: 72 additions & 1 deletion crates/end_model/src/aic_input/model/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use generativity::Id;
use thiserror::Error;
use vector_map::VecMap;

use crate::{DisplayName, ItemId, Key, PosF64};
use crate::{DisplayName, FacilityId, ItemId, Key, PosF64};

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Stage2Weights {
Expand Down Expand Up @@ -163,6 +163,76 @@ impl<'id> IntoIterator for ItemU32Map<'id> {
}
}

#[derive(Debug, Clone, Default, PartialEq)]
pub struct FacilityU32Map<'id>(VecMap<FacilityId<'id>, u32>);

impl<'id> FacilityU32Map<'id> {
pub fn new() -> Self {
Self(VecMap::new())
}

pub fn with_capacity(capacity: usize) -> Self {
Self(VecMap::with_capacity(capacity))
}

pub fn len(&self) -> usize {
self.0.len()
}

pub fn is_empty(&self) -> bool {
self.0.is_empty()
}

pub fn insert(&mut self, facility: FacilityId<'id>, value: u32) -> Option<u32> {
self.0.insert(facility, value)
}

pub fn get(&self, facility: FacilityId<'id>) -> Option<&u32> {
self.0.get(&facility)
}

pub fn iter(&self) -> impl Iterator<Item = (FacilityId<'id>, u32)> + '_ {
self.0.iter().map(|(facility, value)| (*facility, *value))
}
}

impl<'id> Extend<(FacilityId<'id>, u32)> for FacilityU32Map<'id> {
fn extend<T: IntoIterator<Item = (FacilityId<'id>, u32)>>(&mut self, iter: T) {
for (facility, value) in iter {
self.insert(facility, value);
}
}
}

impl<'id> FromIterator<(FacilityId<'id>, u32)> for FacilityU32Map<'id> {
fn from_iter<T: IntoIterator<Item = (FacilityId<'id>, u32)>>(iter: T) -> Self {
let mut map = Self::new();
map.extend(iter);
map
}
}

impl<'id, const N: usize> From<[(FacilityId<'id>, u32); N]> for FacilityU32Map<'id> {
fn from(value: [(FacilityId<'id>, u32); N]) -> Self {
value.into_iter().collect()
}
}

impl<'id> From<Vec<(FacilityId<'id>, u32)>> for FacilityU32Map<'id> {
fn from(value: Vec<(FacilityId<'id>, u32)>) -> Self {
value.into_iter().collect()
}
}

impl<'id> IntoIterator for FacilityU32Map<'id> {
type Item = (FacilityId<'id>, u32);
type IntoIter = vector_map::IntoIter<FacilityId<'id>, u32>;

fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}

#[derive(Debug, Clone, Default, PartialEq)]
pub struct ItemNonZeroU32Map<'id>(VecMap<ItemId<'id>, NonZeroU32>);

Expand Down Expand Up @@ -317,6 +387,7 @@ pub struct AicInputs<'cid, 'sid> {
pub(super) region: Region,
pub(super) supply_per_min: ItemPosF64Map<'cid>,
pub(super) external_consumption_per_min: ItemPosF64Map<'cid>,
pub(super) facility_machines_max: FacilityU32Map<'cid>,
pub(super) outposts: Box<[OutpostInput<'cid>]>,
pub(super) power_config: PowerConfig,
pub(super) stage2_weights: Stage2Weights,
Expand Down
4 changes: 2 additions & 2 deletions crates/end_model/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ mod optimization;
mod text;

pub use aic_input::{
AicBuildError, AicInputs, AicInputsBuilder, ItemNonZeroU32Map, ItemPosF64Map, ItemU32Map, OutpostId,
OutpostInput, PowerConfig, Region, Stage2Weights,
AicBuildError, AicInputs, AicInputsBuilder, FacilityU32Map, ItemNonZeroU32Map, ItemPosF64Map,
ItemU32Map, OutpostId, OutpostInput, PowerConfig, Region, Stage2Weights,
};
pub use catalog::{
Catalog, CatalogBuildError, CatalogBuilder, FacilityDef, FacilityId, FacilityRegions, ItemDef,
Expand Down
12 changes: 12 additions & 0 deletions crates/end_opt/src/solver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,18 @@ fn solve_stage<'cid, 'sid>(
model.with(constraint!(rv.x <= rv.throughput_per_min * rv.y))
});

model = aic
.facility_machines_max()
.iter()
.fold(model, |model, (facility, max_machines)| {
let facility_machines: Expression = recipe_vars
.iter()
.filter(|rv| rv.facility == facility)
.map(|rv| rv.y)
.sum();
model.with(constraint!(facility_machines <= max_machines as f64))
});

// Apply item balance constraints:
// - For fluids: equality (must be exactly 0, no storage allowed)
// - For non-fluids: inequality (>= 0, surplus can go to warehouse)
Expand Down
99 changes: 97 additions & 2 deletions crates/end_opt/tests/two_stage_regression.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]

use end_model::{
AicInputs, Catalog, DisplayName, FacilityDef, FacilityRegions, ItemDef, Key, OutpostInput,
PosF64, PowerConfig, Region, Stack, Stage2Weights, ThermalBankDef,
AicInputs, Catalog, DisplayName, FacilityDef, FacilityRegions, FacilityU32Map, ItemDef, Key,
OutpostInput, PosF64, PowerConfig, Region, Stack, Stage2Weights, ThermalBankDef,
};
use end_opt::{Error, NEAR_INT_EPS, run_two_stage};
use generativity::Guard;
Expand Down Expand Up @@ -270,6 +270,101 @@ fn run_two_stage_allows_empty_recipes_with_direct_external_sales() {
);
}

#[test]
fn run_two_stage_respects_facility_machines_max_constraint() {
make_guard!(guard);
let mut b = Catalog::builder(guard);
let ore = b
.add_item(ItemDef {
key: key("Ore"),
en: name("Ore"),
zh: name("Ore_zh"),
is_fluid: false,
})
.expect("add ore");
let ingot = b
.add_item(ItemDef {
key: key("Ingot"),
en: name("Ingot"),
zh: name("Ingot_zh"),
is_fluid: false,
})
.expect("add ingot");

let smelter = b
.add_facility(FacilityDef {
key: key("Smelter"),
power_w: nz(10),
en: name("Smelter"),
zh: name("Smelter_zh"),
regions: FacilityRegions::All,
})
.expect("add machine");

let mut b = b
.add_thermal_bank(ThermalBankDef {
key: key("Thermal Bank"),
en: name("Thermal Bank"),
zh: name("Thermal_Bank_zh"),
})
.expect("add thermal bank");

b.push_recipe(
smelter,
nz(60),
vec![Stack {
item: ore,
count: nz(1),
}]
.into(),
vec![Stack {
item: ingot,
count: nz(1),
}]
.into(),
)
.expect("push recipe");

let catalog = b.build();

make_guard!(aic_guard);
let facility_machines_max: FacilityU32Map<'_> = vec![(smelter, 1)].into();
let mut aic_builder = AicInputs::builder(
aic_guard,
PowerConfig::default(),
vec![(ore, PosF64::new(10.0).expect("positive"))].into(),
Default::default(),
)
.facility_machines_max(facility_machines_max);
aic_builder
.add_outpost(OutpostInput {
key: key("Camp"),
en: Some(name("Camp")),
zh: Some(name("Camp_zh")),
money_cap_per_hour: 600,
prices: vec![(ingot, 5)].into(),
})
.expect("valid aic outpost");
let aic = aic_builder.build();

make_guard!(result_guard);
let result = run_two_stage(&catalog, &aic, result_guard).expect("solve sample model");

assert_eq!(
result.stage1.total_machines, 1,
"stage1 should respect facility machine cap"
);
assert_eq!(
result.stage2.total_machines, 1,
"stage2 should respect facility machine cap"
);
assert!(
(result.stage1.revenue_per_min - 5.0).abs() <= 1e-9,
"stage1 revenue should be capped at 1 ingot/min by facility cap, got {}",
result.stage1.revenue_per_min
);
}

#[test]
fn max_money_slack_does_not_virtualize_fluid_surplus_into_stockpile() {
make_guard!(guard);
Expand Down
Loading
Loading