1use crate::asset::{Asset, AssetCapacity, AssetRef, AssetState};
5use crate::commodity::CommodityID;
6use crate::finance::annual_capital_cost;
7use crate::input::format_items_with_cap;
8use crate::model::Model;
9use crate::output::DataWriter;
10use crate::region::RegionID;
11use crate::simulation::PriceMap;
12use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceSelection};
13use crate::units::{
14 Activity, Capacity, Dimensionless, Flow, Money, MoneyPerActivity, MoneyPerCapacity,
15 MoneyPerFlow, Year,
16};
17use anyhow::{Context, Result, anyhow, bail};
18use highs::{HighsModelStatus, RowProblem as Problem, Sense};
19use indexmap::{IndexMap, IndexSet};
20use itertools::{chain, iproduct};
21use log::warn;
22use std::collections::HashMap;
23use std::error::Error;
24use std::ops::Range;
25
26mod constraints;
27use constraints::{ConstraintKeys, add_model_constraints};
28
29pub type FlowMap = IndexMap<(AssetRef, CommodityID, TimeSliceID), Flow>;
31
32type Variable = highs::Col;
37
38type ActivityVariableMap = IndexMap<(AssetRef, TimeSliceID), Variable>;
40
41type CapacityVariableMap = IndexMap<AssetRef, Variable>;
43
44type UnmetDemandVariableMap = IndexMap<(CommodityID, RegionID, TimeSliceID), Variable>;
46
47pub struct VariableMap {
57 activity_vars: ActivityVariableMap,
58 existing_asset_var_idx: Range<usize>,
59 candidate_asset_var_idx: Range<usize>,
60 capacity_vars: CapacityVariableMap,
61 capacity_var_idx: Range<usize>,
62 unmet_demand_vars: UnmetDemandVariableMap,
63 unmet_demand_var_idx: Range<usize>,
64}
65
66impl VariableMap {
67 fn new_with_activity_vars(
78 problem: &mut Problem,
79 model: &Model,
80 input_prices: Option<&PriceMap>,
81 existing_assets: &[AssetRef],
82 candidate_assets: &[AssetRef],
83 year: u32,
84 ) -> Self {
85 let mut activity_vars = ActivityVariableMap::new();
86 let existing_asset_var_idx = add_activity_variables(
87 problem,
88 &mut activity_vars,
89 &model.time_slice_info,
90 input_prices,
91 existing_assets,
92 year,
93 );
94 let candidate_asset_var_idx = add_activity_variables(
95 problem,
96 &mut activity_vars,
97 &model.time_slice_info,
98 input_prices,
99 candidate_assets,
100 year,
101 );
102
103 Self {
104 activity_vars,
105 existing_asset_var_idx,
106 candidate_asset_var_idx,
107 capacity_vars: CapacityVariableMap::new(),
108 capacity_var_idx: Range::default(),
109 unmet_demand_vars: UnmetDemandVariableMap::default(),
110 unmet_demand_var_idx: Range::default(),
111 }
112 }
113
114 fn add_unmet_demand_variables(
122 &mut self,
123 problem: &mut Problem,
124 model: &Model,
125 markets_to_allow_unmet_demand: &[(CommodityID, RegionID)],
126 ) {
127 assert_ne!(markets_to_allow_unmet_demand, []);
128
129 let start = problem.num_cols();
131
132 let voll = model.parameters.value_of_lost_load;
134 self.unmet_demand_vars.extend(
135 iproduct!(
136 markets_to_allow_unmet_demand.iter(),
137 model.time_slice_info.iter_ids()
138 )
139 .map(|((commodity_id, region_id), time_slice)| {
140 let key = (commodity_id.clone(), region_id.clone(), time_slice.clone());
141 let var = problem.add_column(voll.value(), 0.0..);
142 (key, var)
143 }),
144 );
145
146 self.unmet_demand_var_idx = start..problem.num_cols();
147 }
148
149 fn get_activity_var(&self, asset: &AssetRef, time_slice: &TimeSliceID) -> Variable {
151 let key = (asset.clone(), time_slice.clone());
152
153 *self
154 .activity_vars
155 .get(&key)
156 .expect("No asset variable found for given params")
157 }
158
159 fn get_unmet_demand_var(
161 &self,
162 commodity_id: &CommodityID,
163 region_id: &RegionID,
164 time_slice: &TimeSliceID,
165 ) -> Variable {
166 *self
167 .unmet_demand_vars
168 .get(&(commodity_id.clone(), region_id.clone(), time_slice.clone()))
169 .expect("No unmet demand variable for given params")
170 }
171
172 fn activity_var_keys(&self) -> indexmap::map::Keys<'_, (AssetRef, TimeSliceID), Variable> {
174 self.activity_vars.keys()
175 }
176
177 fn iter_capacity_vars(&self) -> impl Iterator<Item = (&AssetRef, Variable)> {
179 self.capacity_vars.iter().map(|(asset, var)| (asset, *var))
180 }
181}
182
183#[allow(clippy::struct_field_names)]
185pub struct Solution<'a> {
186 solution: highs::Solution,
187 variables: VariableMap,
188 time_slice_info: &'a TimeSliceInfo,
189 constraint_keys: ConstraintKeys,
190 pub objective_value: Money,
192}
193
194impl Solution<'_> {
195 pub fn create_flow_map(&self) -> FlowMap {
197 let mut flows = FlowMap::new();
200 for (asset, time_slice, activity) in self.iter_activity_for_existing() {
201 for flow in asset.iter_flows() {
202 let flow_key = (asset.clone(), flow.commodity.id.clone(), time_slice.clone());
203 let flow_value = activity * flow.coeff;
204 flows.insert(flow_key, flow_value);
205 }
206 }
207
208 flows
209 }
210
211 pub fn iter_activity(&self) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
213 self.variables
214 .activity_var_keys()
215 .zip(self.solution.columns())
216 .map(|((asset, time_slice), activity)| (asset, time_slice, Activity(*activity)))
217 }
218
219 pub fn iter_activity_for_existing(
221 &self,
222 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
223 let cols = &self.solution.columns()[self.variables.existing_asset_var_idx.clone()];
224 self.variables
225 .activity_var_keys()
226 .skip(self.variables.existing_asset_var_idx.start)
227 .zip(cols.iter())
228 .map(|((asset, time_slice), &value)| (asset, time_slice, Activity(value)))
229 }
230
231 pub fn iter_activity_for_candidates(
233 &self,
234 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, Activity)> {
235 let cols = &self.solution.columns()[self.variables.candidate_asset_var_idx.clone()];
236 self.variables
237 .activity_var_keys()
238 .skip(self.variables.candidate_asset_var_idx.start)
239 .zip(cols.iter())
240 .map(|((asset, time_slice), &value)| (asset, time_slice, Activity(value)))
241 }
242
243 pub fn iter_activity_keys_for_candidates(
245 &self,
246 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID)> {
247 self.iter_activity_for_candidates()
248 .map(|(asset, time_slice, _activity)| (asset, time_slice))
249 }
250
251 pub fn iter_unmet_demand(
253 &self,
254 ) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, Flow)> {
255 self.variables
256 .unmet_demand_vars
257 .keys()
258 .zip(self.solution.columns()[self.variables.unmet_demand_var_idx.clone()].iter())
259 .map(|((commodity_id, region_id, time_slice), flow)| {
260 (commodity_id, region_id, time_slice, Flow(*flow))
261 })
262 }
263
264 pub fn iter_capacity(&self) -> impl Iterator<Item = (&AssetRef, AssetCapacity)> {
266 self.variables
267 .capacity_vars
268 .keys()
269 .zip(self.solution.columns()[self.variables.capacity_var_idx.clone()].iter())
270 .map(|(asset, capacity_var)| {
271 let tranche_size = asset.capacity().tranche_size();
273 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
274 let asset_capacity = AssetCapacity::new(capacity_var.round() as u32, tranche_size);
275
276 (asset, asset_capacity)
277 })
278 }
279
280 pub fn iter_commodity_balance_duals(
282 &self,
283 ) -> impl Iterator<Item = (&CommodityID, &RegionID, &TimeSliceID, MoneyPerFlow)> {
284 self.constraint_keys
288 .commodity_balance_keys
289 .zip_duals(self.solution.dual_rows())
290 .flat_map(|((commodity_id, region_id, ts_selection), price)| {
291 ts_selection
292 .iter(self.time_slice_info)
293 .map(move |(ts, _)| (commodity_id, region_id, ts, price))
294 })
295 }
296
297 pub fn iter_activity_duals(
306 &self,
307 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, MoneyPerActivity)> {
308 self.constraint_keys
309 .activity_keys
310 .zip_duals(self.solution.dual_rows())
311 .filter(|&((_asset, ts_selection), _dual)| {
312 matches!(ts_selection, TimeSliceSelection::Single(_))
313 })
314 .map(|((asset, ts_selection), dual)| {
315 let (time_slice, _) = ts_selection.iter(self.time_slice_info).next().unwrap();
317 (asset, time_slice, dual)
318 })
319 }
320
321 pub fn iter_column_duals(
323 &self,
324 ) -> impl Iterator<Item = (&AssetRef, &TimeSliceID, MoneyPerActivity)> {
325 self.variables
326 .activity_var_keys()
327 .zip(self.solution.dual_columns())
328 .map(|((asset, time_slice), dual)| (asset, time_slice, MoneyPerActivity(*dual)))
329 }
330}
331
332#[derive(Debug, derive_more::Display, derive_more::From)]
334pub enum ModelError {
335 #[display("Could not find optimal result: {_0:?}")]
337 NonOptimal(HighsModelStatus),
338 #[display("{_0}")]
340 Other(anyhow::Error),
341}
342
343impl ModelError {
344 pub fn into_anyhow(self) -> anyhow::Error {
346 match self {
347 ModelError::NonOptimal(status) => anyhow!("Could not find optimal result: {status:?}"),
348 ModelError::Other(error) => error,
349 }
350 }
351}
352
353impl Error for ModelError {
354 fn source(&self) -> Option<&(dyn Error + 'static)> {
355 match self {
356 ModelError::NonOptimal(_) => None,
357 ModelError::Other(error) => Some(error.as_ref()),
358 }
359 }
360}
361
362pub fn apply_highs_options_from_toml(
364 model: &mut highs::Model,
365 options: &toml::Table,
366) -> Result<()> {
367 macro_rules! try_set_opt {
369 ($option:expr, $value:expr) => {{
370 model
371 .try_set_option($option.as_str(), $value)
372 .map_err(|_| anyhow!("Invalid option name or value"))?;
373
374 Ok(())
375 }};
376 }
377
378 for (option, value) in options {
380 match value {
381 toml::Value::String(value) => try_set_opt!(option, value.as_str()),
382 toml::Value::Integer(value) => match i32::try_from(*value) {
383 Ok(value) => try_set_opt!(option, value),
384 Err(_) => Err(anyhow!("Value out of range")),
385 },
386 toml::Value::Float(value) => try_set_opt!(option, *value),
387 toml::Value::Boolean(value) => try_set_opt!(option, *value),
388 _ => Err(anyhow!("HiGHS options cannot have this type")),
389 }
390 .with_context(|| format!("Failed to set option \"{option}\" to value \"{value}\""))?;
391 }
392
393 Ok(())
394}
395
396pub fn solve_optimal(model: highs::Model) -> Result<highs::SolvedModel, ModelError> {
398 let solved = model
399 .try_solve()
400 .map_err(|status| anyhow!("Incoherent model: {status:?}"))?;
401
402 match solved.status() {
403 HighsModelStatus::Optimal => Ok(solved),
404 status => Err(status.into()),
405 }
406}
407
408fn filter_input_prices(
413 input_prices: &PriceMap,
414 markets_to_balance: &[(CommodityID, RegionID)],
415) -> PriceMap {
416 input_prices
417 .iter()
418 .filter(|(commodity_id, region_id, _, _)| {
419 !markets_to_balance
420 .iter()
421 .any(|(c, r)| c == *commodity_id && r == *region_id)
422 })
423 .collect()
424}
425
426#[doc = concat!("[1]: ", crate::docs_url!("/model/dispatch_optimisation.html"))]
435#[must_use = "Must call run() method on DispatchRun struct"]
436pub struct DispatchRun<'model, 'run> {
437 model: &'model Model,
438 existing_assets: &'run [AssetRef],
439 flexible_capacity_assets: &'run [AssetRef],
440 capacity_limits: Option<&'run HashMap<AssetRef, Capacity>>,
441 candidate_assets: &'run [AssetRef],
442 markets_to_balance: &'run [(CommodityID, RegionID)],
443 input_prices: Option<&'run PriceMap>,
444 include_commodity_constraints: bool,
445 year: u32,
446 capacity_margin: Dimensionless,
447}
448
449impl<'model, 'run> DispatchRun<'model, 'run> {
450 pub fn new(model: &'model Model, assets: &'run [AssetRef], year: u32) -> Self {
452 Self {
453 model,
454 existing_assets: assets,
455 flexible_capacity_assets: &[],
456 capacity_limits: None,
457 candidate_assets: &[],
458 markets_to_balance: &[],
459 input_prices: None,
460 include_commodity_constraints: true,
461 year,
462 capacity_margin: Dimensionless(0.0),
463 }
464 }
465
466 pub fn with_flexible_capacity_assets(
468 self,
469 flexible_capacity_assets: &'run [AssetRef],
470 capacity_limits: Option<&'run HashMap<AssetRef, Capacity>>,
471 capacity_margin: Dimensionless,
472 ) -> Self {
473 Self {
474 flexible_capacity_assets,
475 capacity_limits,
476 capacity_margin,
477 ..self
478 }
479 }
480
481 pub fn with_candidates(self, candidate_assets: &'run [AssetRef]) -> Self {
483 Self {
484 candidate_assets,
485 ..self
486 }
487 }
488
489 pub fn without_commodity_constraints(self) -> Self {
491 Self {
492 include_commodity_constraints: false,
493 ..self
494 }
495 }
496
497 pub fn with_market_balance_subset(
499 self,
500 markets_to_balance: &'run [(CommodityID, RegionID)],
501 ) -> Self {
502 assert_ne!(markets_to_balance, []);
503
504 Self {
505 markets_to_balance,
506 ..self
507 }
508 }
509
510 pub fn with_input_prices(self, input_prices: &'run PriceMap) -> Self {
512 Self {
513 input_prices: Some(input_prices),
514 ..self
515 }
516 }
517
518 pub fn run(&self, run_description: &str, writer: &mut DataWriter) -> Result<Solution<'model>> {
530 let all_markets: Vec<_>;
532 let markets_to_balance = if self.markets_to_balance.is_empty() {
533 all_markets = self.model.iter_markets().collect();
534 &all_markets
535 } else {
536 self.markets_to_balance
537 };
538
539 let input_prices_owned = self
541 .input_prices
542 .map(|prices| filter_input_prices(prices, markets_to_balance));
543 let input_prices = input_prices_owned.as_ref();
544
545 match self.run_without_unmet_demand_variables(markets_to_balance, input_prices) {
548 Ok(solution) => {
550 writer.write_dispatch_debug_info(self.year, run_description, &solution)?;
551 Ok(solution)
552 }
553
554 Err(ModelError::NonOptimal(HighsModelStatus::Infeasible)) => {
557 let mut diagnoses = vec![
560 "The solver has indicated that the dispatch problem is infeasible".to_string(),
561 ];
562
563 if let Some(diagnosis) = self.run_unmet_demand_diagnostic(
565 markets_to_balance,
566 input_prices,
567 run_description,
568 writer,
569 )? {
570 diagnoses.push(diagnosis);
571 }
572
573 if self.has_commodity_constraints()
575 && let Some(diagnosis) = self.run_commodity_constraints_diagnosis(
576 markets_to_balance,
577 input_prices,
578 run_description,
579 writer,
580 )?
581 {
582 diagnoses.push(diagnosis);
583 }
584
585 bail!("{}.", diagnoses.join(". "));
587 }
588
589 Err(err) => Err(err.into_anyhow()),
591 }
592 }
593
594 fn has_commodity_constraints(&self) -> bool {
596 self.model.commodities.values().any(|commodity| {
597 commodity
598 .constraints
599 .get(&self.year)
600 .is_some_and(|constraints| !constraints.is_empty())
601 })
602 }
603
604 fn run_commodity_constraints_diagnosis(
606 &self,
607 markets_to_balance: &[(CommodityID, RegionID)],
608 input_prices: Option<&PriceMap>,
609 run_description: &str,
610 writer: &mut DataWriter,
611 ) -> Result<Option<String>> {
612 if !self.include_commodity_constraints {
613 return Ok(None);
614 }
615
616 warn!("Dispatch optimisation was infeasible; running commodity constraints diagnostic");
617
618 match self.run_internal(
619 markets_to_balance,
620 false,
621 false,
622 input_prices,
623 ) {
624 Ok(solution) => {
625 let diagnostic_run_description =
626 format!("{run_description} COMMODITY_CONSTRAINTS_DIAGNOSTIC");
627 writer.write_dispatch_debug_info(
628 self.year,
629 &diagnostic_run_description,
630 &solution,
631 )?;
632
633 Ok(Some(
634 "The infeasibility is likely caused by one or more constraints defined in \
635 `commodity_constraints.csv`. Please note that commodity constraints are \
636 currently an experimental feature, so this is not necessarily unexpected"
637 .to_string(),
638 ))
639 }
640
641 Err(ModelError::NonOptimal(HighsModelStatus::Infeasible)) => Ok(None),
644
645 Err(error) => Err(error.into_anyhow()),
647 }
648 }
649
650 fn run_unmet_demand_diagnostic(
652 &self,
653 markets_to_balance: &[(CommodityID, RegionID)],
654 input_prices: Option<&PriceMap>,
655 run_description: &str,
656 writer: &mut DataWriter,
657 ) -> Result<Option<String>> {
658 warn!("Dispatch optimisation was infeasible; running unmet demand diagnostic");
659
660 match self.run_internal(
661 markets_to_balance,
662 self.include_commodity_constraints,
663 true,
664 input_prices,
665 ) {
666 Ok(solution) => {
667 let diagnostic_run_description =
670 format!("{run_description} UNMET_DEMAND_DIAGNOSTIC");
671 writer.write_dispatch_debug_info(
672 self.year,
673 &diagnostic_run_description,
674 &solution,
675 )?;
676
677 let markets: IndexSet<_> = solution
679 .iter_unmet_demand()
680 .filter(|(_, _, _, flow)| *flow > Flow(0.0))
681 .map(|(commodity_id, region_id, _, _)| {
682 (commodity_id.clone(), region_id.clone())
683 })
684 .collect();
685
686 Ok(Some(if markets.is_empty() {
687 "No unmet demand was identified".to_string()
688 } else {
689 format!(
690 "Demand was not met for the following markets: {}",
691 format_items_with_cap(markets)
692 )
693 }))
694 }
695
696 Err(ModelError::NonOptimal(HighsModelStatus::Infeasible)) => Ok(None),
699
700 Err(error) => Err(error.into_anyhow()),
702 }
703 }
704
705 fn run_without_unmet_demand_variables(
707 &self,
708 markets_to_balance: &[(CommodityID, RegionID)],
709 input_prices: Option<&PriceMap>,
710 ) -> Result<Solution<'model>, ModelError> {
711 self.run_internal(
712 markets_to_balance,
713 self.include_commodity_constraints,
714 false,
715 input_prices,
716 )
717 }
718
719 fn run_internal(
721 &self,
722 markets_to_balance: &[(CommodityID, RegionID)],
723 include_commodity_constraints: bool,
724 allow_unmet_demand: bool,
725 input_prices: Option<&PriceMap>,
726 ) -> Result<Solution<'model>, ModelError> {
727 let mut problem = Problem::default();
729 let mut variables = VariableMap::new_with_activity_vars(
730 &mut problem,
731 self.model,
732 input_prices,
733 self.existing_assets,
734 self.candidate_assets,
735 self.year,
736 );
737
738 if allow_unmet_demand {
741 variables.add_unmet_demand_variables(&mut problem, self.model, markets_to_balance);
742 }
743
744 for asset in self.flexible_capacity_assets {
746 assert!(
747 self.existing_assets.contains(asset),
748 "Flexible capacity assets must be a subset of existing assets. Offending asset: {asset:?}"
749 );
750 }
751
752 if !self.flexible_capacity_assets.is_empty() {
754 variables.capacity_var_idx = add_capacity_variables(
755 &mut problem,
756 &mut variables.capacity_vars,
757 self.flexible_capacity_assets,
758 self.capacity_limits,
759 self.capacity_margin,
760 );
761 }
762
763 let all_assets = chain(self.existing_assets.iter(), self.candidate_assets.iter());
765 let constraint_keys = add_model_constraints(
766 &mut problem,
767 &variables,
768 self.model,
769 &all_assets,
770 markets_to_balance,
771 self.year,
772 self.candidate_assets,
773 include_commodity_constraints,
774 );
775
776 let mut model = problem.optimise(Sense::Minimise);
778 apply_highs_options_from_toml(&mut model, &self.model.parameters.highs.dispatch_options)
779 .context("Failed to apply custom HiGHS options to dispatch optimisation")?;
780 let solution = solve_optimal(model)?;
781
782 Ok(Solution {
783 solution: solution.get_solution(),
784 variables,
785 time_slice_info: &self.model.time_slice_info,
786 constraint_keys,
787 objective_value: Money(solution.objective_value()),
788 })
789 }
790}
791
792fn add_activity_variables(
803 problem: &mut Problem,
804 variables: &mut ActivityVariableMap,
805 time_slice_info: &TimeSliceInfo,
806 input_prices: Option<&PriceMap>,
807 assets: &[AssetRef],
808 year: u32,
809) -> Range<usize> {
810 let start = problem.num_cols();
812
813 for (asset, time_slice) in iproduct!(assets.iter(), time_slice_info.iter_ids()) {
814 let coeff = calculate_activity_coefficient(asset, year, time_slice, input_prices);
815 let var = problem.add_column(coeff.value(), 0.0..);
816 let key = (asset.clone(), time_slice.clone());
817 let existing = variables.insert(key, var).is_some();
818 assert!(!existing, "Duplicate entry for var");
819 }
820
821 start..problem.num_cols()
822}
823
824fn add_capacity_variables(
825 problem: &mut Problem,
826 variables: &mut CapacityVariableMap,
827 assets: &[AssetRef],
828 capacity_limits: Option<&HashMap<AssetRef, Capacity>>,
829 capacity_margin: Dimensionless,
830) -> Range<usize> {
831 let capacity_margin = capacity_margin.value();
832
833 let start = problem.num_cols();
835
836 for asset in assets {
837 assert!(
839 matches!(asset.state(), AssetState::Ready { .. }),
840 "Flexible capacity can only be assigned to `Ready` type assets. Offending asset: {asset:?}"
841 );
842
843 let coeff = calculate_capacity_coefficient(asset);
845
846 let tranche_size = asset.capacity().tranche_size();
851 let current_tranches = asset.capacity().num_tranches();
852
853 let lower = (current_tranches as f64 * (1.0 - capacity_margin)).max(0.0);
854
855 let mut upper = current_tranches as f64 * (1.0 + capacity_margin);
856 if let Some(limit) = capacity_limits.and_then(|limits| limits.get(asset)) {
857 upper = upper.min((*limit / tranche_size).value());
858 }
859
860 let var = problem.add_integer_column((coeff * tranche_size).value(), lower..=upper);
861
862 let existing = variables.insert(asset.clone(), var).is_some();
863 assert!(!existing, "Duplicate entry for var");
864 }
865
866 start..problem.num_cols()
867}
868
869fn calculate_activity_coefficient(
886 asset: &Asset,
887 year: u32,
888 time_slice: &TimeSliceID,
889 input_prices: Option<&PriceMap>,
890) -> MoneyPerActivity {
891 let opex = asset.get_operating_cost(year, time_slice);
892 if let Some(prices) = input_prices {
893 opex + asset.get_input_cost_from_prices(prices, time_slice)
894 } else {
895 opex
896 }
897}
898
899fn calculate_capacity_coefficient(asset: &AssetRef) -> MoneyPerCapacity {
903 let param = asset.process_parameter();
904 let annual_fixed_operating_cost = param.fixed_operating_cost * Year(1.0);
905 annual_fixed_operating_cost
906 + annual_capital_cost(param.capital_cost, param.lifetime, param.discount_rate)
907}