1use std::sync::Arc;
4
5use egui::TextureHandle;
6use henad_compute::cpu::sim_thread::{SimCommand, SimThread, WakeFn};
7use henad_compute::entry::{ModelEntry, ModelLookupError, ModelSet, ModelState};
8use henad_compute::fault::{BUILDING, Fault, catching};
9use henad_compute::simulation::{RunSetup, SetupError};
10use henad_compute::snapshot::Snapshot;
11use henad_core::action::Schedule;
12use henad_core::explore::replay::Replay;
13use henad_core::params::ParamValue;
14use henad_core::view::StatsHistory;
15use henad_explore::output::memory::SweepFiles;
16
17use crate::options::{AppOpening, Product};
18use crate::sim_runner::SimRunner;
19use crate::ui::agent_layer::AgentLayer;
20use crate::ui::dock::Tab;
21use crate::ui::edge_layer::EdgeStyle;
22use crate::ui::export::Recording;
23use crate::ui::export::image::PendingCapture;
24use crate::ui::files::open::spawn_open;
25use crate::ui::files::save::{spawn_save, spawn_save_files};
26use crate::ui::files::{OpenOutcome, OpenTarget, SaveOutcome, SaveResult, SaveTarget};
27use crate::ui::results::ResultsPanel;
28use crate::ui::sweep::SweepPanel;
29use henad_compute::runtime_info::RuntimeInfo;
30
31use henad_compute::gpu::GpuContext;
32use henad_compute::gpu::fault::catching_on;
33use henad_compute::gpu::sim_thread::{GpuBatchSettings, GpuSimThread};
34use henad_compute::gpu::timing::{DEFAULT_BATCH_SIZE, DEFAULT_TARGET_MS};
35
36const EMA_ALPHA: f64 = 0.1;
38
39pub const DEFAULT_HISTORY_LEN: usize = 10_000;
41
42const DEFAULT_LAYOUT_BUDGET_MS: f32 = 4.0;
44
45#[cfg(not(target_arch = "wasm32"))]
47const SAVE_PENDING: &str = "Select location to save";
48
49#[cfg(target_arch = "wasm32")]
51const SAVE_PENDING: &str = "Downloading";
52
53const DOWNLOADS_STARTED: &str = "Downloads started. Press Save results again if your browser blocked any.";
55
56#[derive(Default)]
58pub struct FrameTimings {
59 pub render_ms: f64,
60 pub ui_ms: f64,
61 pub frame_render_ms: f64,
63}
64
65impl FrameTimings {
66 pub fn update_ema(smoothed: &mut f64, sample_ms: f64) {
67 *smoothed += EMA_ALPHA * (sample_ms - *smoothed);
68 }
69}
70
71pub struct AppState {
72 egui_ctx: egui::Context,
74 pub models: ModelSet,
76 pub product: Product,
78 pub opening_refusal: Option<OpeningRefusal>,
80 pub selected_model: Option<String>,
82 pub param_values: Vec<ParamValue>,
83 pub loaded_model: Option<String>,
85 pub pending_reload: Vec<bool>,
86 pub seed: Option<u64>,
88 pub seed_text: String,
90 pub loaded_values: Vec<ParamValue>,
93 pub loaded_seed: Option<u64>,
95 pub schedule: Schedule,
97 pub loaded_schedule: Schedule,
99 pub schedule_action_input: usize,
101 pub schedule_tick_input: u64,
102 pub opened_run: Option<OpenedRun>,
104 pub run_to_target: Option<u64>,
106 pub run_to_input: u64,
107 pub focus_request: Option<Tab>,
109 pub sim_thread: Option<SimRunner>,
110 pub snapshot: Option<Snapshot>,
111 pub sim_running: bool,
112 pub grid_texture: Option<TextureHandle>,
113 pub density_texture: Option<TextureHandle>,
115 pub density_max: f32,
116 pub point_render_mode: PointRenderMode,
117 pub show_edges: bool,
119 pub edge_arrows: bool,
120 pub agent_layer: Option<AgentLayer>,
122 pub last_rendered_serial: Option<u64>,
124 pub rendering_enabled: bool,
125 pub target_tps: f64,
126 pub uncapped: bool,
127 pub ticks_per_snapshot: u32,
128 pub layout_on: bool,
130 pub layout_budget_ms: f32,
131 pub layout_while_paused: bool,
132 pub stats_history: Option<StatsHistory>,
133 pub history_capacity: Option<usize>,
135 pub history_len: usize,
137 pub runtime: RuntimeInfo,
139 pub render_ctx: GpuContext,
143 pub fault: Option<ShownFault>,
145 pub about_open: bool,
146 pub thread_pool_note: Option<String>,
148 pub logo_texture: std::cell::OnceCell<Option<TextureHandle>>,
150 pub timings: FrameTimings,
151 pub gpu_ctx: Option<GpuContext>,
154 pub capture: Option<PendingCapture>,
156 pub recording: Recording,
157 pub export_status: Option<String>,
159 saves: (flume::Sender<SaveOutcome>, flume::Receiver<SaveOutcome>),
161 opens: (flume::Sender<OpenOutcome>, flume::Receiver<OpenOutcome>),
163 pub sweep: SweepPanel,
164 pub results: ResultsPanel,
165 pub gpu_adaptive: bool,
167 pub gpu_target_ms: f64,
168 pub gpu_batch_size: u32,
169}
170
171#[derive(Debug)]
173pub struct ShownFault {
174 pub fault: Fault,
175 pub model: Option<String>,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct OpeningRefusal {
182 pub lead: &'static str,
184 pub reason: String,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190#[non_exhaustive]
191pub enum OpenAt {
192 Start,
194 Tick(u64),
196}
197
198#[derive(Debug, Clone, PartialEq)]
200pub struct OpenedRun {
201 pub replay: Replay,
202 pub modified: bool,
204}
205
206impl OpenedRun {
207 pub fn is_built_by(&self, params: &[ParamValue], seed: Option<u64>, schedule: &Schedule) -> bool {
209 params == self.replay.params.as_slice() && seed == Some(self.replay.seed) && *schedule == self.replay.schedule
210 }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
215pub enum PointRenderMode {
216 #[default]
217 Agents,
218 Density,
221}
222
223impl AppState {
224 pub fn new(
230 egui_ctx: egui::Context,
231 models: ModelSet,
232 product: Product,
233 render_ctx: GpuContext,
234 gpu_ctx: Option<GpuContext>,
235 runtime: RuntimeInfo,
236 ) -> Self {
237 let first = models.runnable(gpu_ctx.as_ref()).next();
238 let selected_model = first.map(|entry| entry.id().to_owned());
239 let param_values: Vec<ParamValue> = first.map(default_values).unwrap_or_default();
240 let mut sweep = SweepPanel::default();
241 sweep.cli_command.clone_from(&product.cli_command);
242
243 Self {
244 egui_ctx,
245 pending_reload: vec![false; param_values.len()],
246 models,
247 product,
248 opening_refusal: None,
249 selected_model,
250 param_values,
251 loaded_model: None,
252 seed: None,
253 seed_text: String::new(),
254 loaded_values: Vec::new(),
255 loaded_seed: None,
256 schedule: Schedule::default(),
257 loaded_schedule: Schedule::default(),
258 schedule_action_input: 0,
259 schedule_tick_input: 0,
260 opened_run: None,
261 run_to_target: None,
262 run_to_input: 0,
263 focus_request: None,
264 sim_thread: None,
265 snapshot: None,
266 sim_running: false,
267 grid_texture: None,
268 density_texture: None,
269 density_max: 4.0,
270 point_render_mode: PointRenderMode::default(),
271 show_edges: true,
272 edge_arrows: false,
273 agent_layer: None,
274 last_rendered_serial: None,
275 rendering_enabled: true,
276 target_tps: 30.0,
277 uncapped: false,
278 ticks_per_snapshot: 1,
279 layout_on: true,
280 layout_budget_ms: DEFAULT_LAYOUT_BUDGET_MS,
281 layout_while_paused: false,
282 stats_history: None,
283 history_capacity: Some(DEFAULT_HISTORY_LEN),
284 history_len: DEFAULT_HISTORY_LEN,
285 runtime,
286 render_ctx,
287 fault: None,
288 about_open: false,
289 thread_pool_note: None,
290 logo_texture: std::cell::OnceCell::new(),
291 timings: FrameTimings::default(),
292 gpu_ctx,
293 capture: None,
294 recording: Recording::Off,
295 export_status: None,
296 saves: flume::unbounded(),
297 opens: flume::unbounded(),
298 sweep,
299 results: ResultsPanel::default(),
300 gpu_adaptive: true,
301 gpu_target_ms: DEFAULT_TARGET_MS,
302 gpu_batch_size: DEFAULT_BATCH_SIZE,
303 }
304 }
305
306 pub fn reset_simulation(&mut self) {
311 let setup = match self.build_setup() {
312 Some(Ok(setup)) => Some(setup),
313 Some(Err(error)) => {
314 log::warn!("Build refused: {}", setup_message(&error));
315 return;
316 }
317 None => None,
318 };
319 self.settle_opened_run();
320 self.stop_recording();
321 self.sim_thread = None;
324 drop(self.render_ctx.faults.take());
325 self.snapshot = None;
326 self.last_rendered_serial = None;
327 self.grid_texture = None;
328 self.density_texture = None;
329 self.density_max = 4.0;
330 self.ticks_per_snapshot = 1;
331 self.loaded_model = None;
332 self.export_status = None;
333 self.run_to_target = None;
334 if let Some(layer) = &mut self.agent_layer {
336 layer.clear();
337 }
338
339 let Some(setup) = setup else {
340 return;
341 };
342
343 let stats_history = StatsHistory::new(setup.entry().stat_descriptors().to_vec(), self.history_capacity);
344
345 match self.build_runner(&setup, &self.repaint_waker()) {
346 Ok(mut runner) => {
347 if !self.schedule.is_empty() {
348 runner.send(SimCommand::SetSchedule(self.schedule.clone()));
349 }
350 self.sim_thread = Some(runner);
351 }
352 Err(fault) => {
353 self.report_fault(fault);
354 return;
355 }
356 }
357
358 self.stats_history = Some(stats_history);
359 self.sim_running = false;
360 self.loaded_model.clone_from(&self.selected_model);
361 self.pending_reload = vec![false; self.param_values.len()];
362 self.loaded_values.clone_from(&self.param_values);
363 self.loaded_seed = self.seed;
364 self.loaded_schedule = self.schedule.clone();
365 }
366
367 fn settle_opened_run(&mut self) {
372 let Some(run) = &mut self.opened_run else {
373 return;
374 };
375 if self.selected_model.as_deref() != Some(run.replay.model.as_str()) {
376 self.opened_run = None;
377 return;
378 }
379 run.modified = !run.is_built_by(&self.param_values, self.seed, &self.schedule);
380 }
381
382 pub fn open(&mut self, opening: AppOpening) {
387 let opened = match opening {
388 #[cfg(not(target_arch = "wasm32"))]
389 AppOpening::Results(folder) => {
390 crate::ui::results::open_folder(self, folder);
391 self.focus_request = Some(Tab::Results);
392 Ok(())
393 }
394 AppOpening::Run { replay, open_at } => self.open_run(replay, open_at).map_err(|reason| OpeningRefusal {
395 lead: "Run not opened",
396 reason,
397 }),
398 AppOpening::Setup { setup, open_at } => self.open_setup(&setup, open_at).map_err(|reason| OpeningRefusal {
399 lead: "Model not opened",
400 reason,
401 }),
402 };
403 if let Err(refusal) = opened {
404 log::warn!("{}: {}", refusal.lead, refusal.reason);
405 self.selected_model = None;
406 self.param_values.clear();
407 self.pending_reload.clear();
408 self.schedule = Schedule::default();
409 self.opening_refusal = Some(refusal);
410 self.focus_request = Some(Tab::Model);
411 }
412 }
413
414 pub fn open_run(&mut self, replay: Replay, start: OpenAt) -> Result<(), String> {
421 let declared = self
422 .lookup(&replay.model)
423 .map_err(|error| lookup_message(&error))?
424 .param_descriptors()
425 .len();
426 if replay.params.len() != declared {
427 return Err(format!(
428 "This run sets {} {}, but model '{}' has {declared}",
429 replay.params.len(),
430 crate::ui::plural(replay.params.len() as u64, "parameter"),
431 replay.model
432 ));
433 }
434
435 let entry = self.lookup(&replay.model).map_err(|error| lookup_message(&error))?;
436 RunSetup::from_replay(entry, &replay).map_err(|error| setup_message(&error))?;
437
438 let built = self.open_values(
439 &replay.model,
440 &replay.params,
441 Some(replay.seed),
442 &replay.schedule,
443 start,
444 );
445 if built {
446 self.opened_run = Some(OpenedRun {
447 replay,
448 modified: false,
449 });
450 }
451 Ok(())
452 }
453
454 pub fn open_setup(&mut self, setup: &RunSetup, start: OpenAt) -> Result<(), String> {
464 let id = setup.entry().id();
465 let entry = self.lookup(id).map_err(|error| lookup_message(&error))?;
466 RunSetup::from_parts(entry, setup.values(), setup.seed(), setup.schedule().clone())
467 .map_err(|error| setup_message(&error))?;
468 self.open_values(id, setup.values(), setup.seed(), setup.schedule(), start);
469 Ok(())
470 }
471
472 fn open_values(
475 &mut self,
476 id: &str,
477 params: &[ParamValue],
478 seed: Option<u64>,
479 schedule: &Schedule,
480 start: OpenAt,
481 ) -> bool {
482 self.opened_run = None;
483 self.opening_refusal = None;
484 self.selected_model = Some(id.to_owned());
485 self.param_values = params.to_vec();
486 self.pending_reload = vec![false; params.len()];
487 self.seed = seed;
488 self.seed_text = seed.map(|seed| seed.to_string()).unwrap_or_default();
489 self.schedule = schedule.clone();
490 self.schedule_action_input = 0;
491 self.reset_simulation();
492 if self.sim_thread.is_none() {
493 return false;
494 }
495 if let OpenAt::Tick(tick) = start {
496 self.run_to_input = tick;
497 self.run_to(tick);
498 }
499 self.focus_request = Some(Tab::Viewport);
500 true
501 }
502
503 pub fn run_to(&mut self, tick: u64) {
508 if self.snapshot.as_ref().is_some_and(|snap| tick < snap.tick) {
509 self.reset_simulation();
510 }
511 let Some(thread) = &mut self.sim_thread else {
512 return;
513 };
514 thread.send(SimCommand::RunTo(tick));
515 self.sim_running = false;
516 self.run_to_target = Some(tick);
517 }
518
519 pub fn mark_opened_run_modified(&mut self) {
521 if let Some(run) = &mut self.opened_run {
522 run.modified = true;
523 }
524 }
525
526 pub fn seed_pending(&self) -> bool {
528 self.selection_is_loaded() && self.loaded_seed != self.seed
529 }
530
531 pub fn schedule_pending(&self) -> bool {
533 self.selection_is_loaded() && self.loaded_schedule != self.schedule
534 }
535
536 fn build_runner(&self, setup: &RunSetup, wake: &WakeFn) -> Result<SimRunner, Fault> {
542 let entry = setup.entry();
543 match entry.build(setup.values(), setup.seed(), self.gpu_ctx.as_ref())? {
544 ModelState::Cpu(state) => catching(BUILDING, || {
545 let mut thread = SimThread::new(
546 state,
547 self.target_tps,
548 Some(wake.clone()),
549 self.render_ctx.faults.clone(),
550 );
551 if self.uncapped {
552 thread.send(SimCommand::SetUncapped(true));
553 }
554 if entry.topology_hint().edges {
555 thread.send(self.layout_command());
556 }
557 SimRunner::Cpu(thread)
558 }),
559 ModelState::Gpu(state) => {
560 let Some(ctx) = self.gpu_ctx.clone() else {
563 return Err(Fault::refused(BUILDING, "no GPU context is available"));
564 };
565 let settings = GpuBatchSettings {
566 adaptive: self.gpu_adaptive,
567 batch_size: self.gpu_batch_size,
568 target_ms: self.gpu_target_ms,
569 };
570 catching_on(&ctx.clone(), BUILDING, || {
571 SimRunner::Gpu(GpuSimThread::new(ctx, state, settings, Some(wake.clone())))
572 })
573 }
574 }
575 }
576
577 #[cfg_attr(
582 all(target_arch = "wasm32", target_feature = "atomics"),
583 expect(
584 clippy::arc_with_non_send_sync,
585 reason = "a `WakeFn` is not `Send` on wasm with atomics"
586 )
587 )]
588 pub fn repaint_waker(&self) -> WakeFn {
589 let ctx = self.egui_ctx.clone();
590 Arc::new(move || ctx.request_repaint())
591 }
592
593 pub fn pause_simulation(&mut self) {
595 let Some(thread) = &mut self.sim_thread else {
596 return;
597 };
598 if self.sim_running || self.run_to_target.is_some() {
599 thread.pause();
600 }
601 self.sim_running = false;
602 self.run_to_target = None;
603 }
604
605 pub fn report_fault(&mut self, fault: Fault) {
610 log::error!("{fault}");
611 let model = if fault.during == BUILDING {
612 self.selected_entry()
613 } else {
614 self.loaded_entry()
615 };
616 let model = model.map(|entry| entry.name().to_owned());
617 self.offload_simulation();
618 self.fault = Some(ShownFault { fault, model });
619 }
620
621 pub fn offload_simulation(&mut self) {
622 self.stop_recording();
623 self.sim_thread = None;
624 drop(self.render_ctx.faults.take());
625 self.snapshot = None;
626 self.sim_running = false;
627 self.grid_texture = None;
628 self.density_texture = None;
629 self.last_rendered_serial = None;
630 self.stats_history = None;
631 self.loaded_model = None;
632 self.opened_run = None;
633 self.run_to_target = None;
634 if let Some(layer) = &mut self.agent_layer {
635 layer.clear();
636 }
637 }
638
639 pub fn layout_command(&self) -> SimCommand {
640 SimCommand::SetLayout {
641 on: self.layout_on,
642 budget_ms: self.layout_budget_ms,
643 while_paused: self.layout_while_paused,
644 }
645 }
646
647 pub fn edge_style(&self) -> EdgeStyle {
648 EdgeStyle {
649 visible: self.show_edges,
650 arrows: self.edge_arrows,
651 }
652 }
653
654 pub fn selection_is_loaded(&self) -> bool {
655 self.loaded_model.is_some() && self.loaded_model == self.selected_model
656 }
657
658 pub fn selected_entry(&self) -> Option<&ModelEntry> {
660 self.models.get(self.selected_model.as_deref()?)
661 }
662
663 pub fn build_setup(&self) -> Option<Result<RunSetup, SetupError>> {
667 let entry = self.selected_entry()?;
668 Some(RunSetup::from_parts(
669 entry,
670 &self.param_values,
671 self.seed,
672 self.schedule.clone(),
673 ))
674 }
675
676 pub fn loaded_entry(&self) -> Option<&ModelEntry> {
678 self.models.get(self.loaded_model.as_deref()?)
679 }
680
681 pub fn offered_models(&self) -> impl Iterator<Item = &ModelEntry> + '_ {
683 self.models.runnable(self.gpu_ctx.as_ref())
684 }
685
686 pub fn lookup(&self, id: &str) -> Result<&ModelEntry, ModelLookupError> {
693 self.models.lookup(id, self.gpu_ctx.as_ref())
694 }
695
696 pub fn select_model(&mut self, id: &str) {
701 if self.selected_model.as_deref() == Some(id) {
702 return;
703 }
704 self.selected_model = Some(id.to_owned());
705 self.opening_refusal = None;
706 self.schedule_action_input = 0;
707 if self.loaded_model.as_deref() == Some(id) {
708 self.param_values.clone_from(&self.loaded_values);
709 self.pending_reload = vec![false; self.param_values.len()];
710 self.seed = self.loaded_seed;
711 self.seed_text = self.loaded_seed.map(|seed| seed.to_string()).unwrap_or_default();
712 self.schedule = self.loaded_schedule.clone();
713 return;
714 }
715 self.param_values = self.models.get(id).map(default_values).unwrap_or_default();
716 self.pending_reload = vec![false; self.param_values.len()];
717 self.schedule = Schedule::default();
719 }
720
721 pub fn send_live_param(&mut self, index: usize, value: ParamValue) -> bool {
726 if !self.selection_is_loaded() {
727 return false;
728 }
729 let Some(thread) = &mut self.sim_thread else {
730 return false;
731 };
732 thread.send(SimCommand::SetParam {
733 index,
734 value: value.clone(),
735 });
736 if let Some(slot) = self.loaded_values.get_mut(index) {
737 *slot = value;
738 }
739 true
740 }
741
742 pub fn selection_shortfalls(&self) -> Vec<String> {
744 self.selected_entry().map_or_else(Vec::new, |entry| {
745 entry.shortfalls(&self.param_values, &self.runtime.granted)
746 })
747 }
748
749 pub fn is_gpu(&self) -> bool {
750 self.sim_thread.as_ref().is_some_and(|t| t.gpu_stats().is_some())
751 }
752
753 pub fn save_as(&mut self, target: SaveTarget, name: &str, bytes: Vec<u8>) {
757 *self.save_status(target) = Some(format!("{SAVE_PENDING} {name}"));
758 spawn_save(
759 target,
760 name.to_owned(),
761 bytes,
762 self.saves.0.clone(),
763 self.egui_ctx.clone(),
764 );
765 }
766
767 pub fn save_files(&mut self, target: SaveTarget, files: Arc<SweepFiles>) {
771 *self.save_status(target) = Some(format!("{SAVE_PENDING} {} files", files.entries().len()));
772 spawn_save_files(target, files, self.saves.0.clone(), self.egui_ctx.clone());
773 }
774
775 fn save_status(&mut self, target: SaveTarget) -> &mut Option<String> {
777 match target {
778 SaveTarget::Export => &mut self.export_status,
779 SaveTarget::SweepSpec | SaveTarget::SweepResults(_) => &mut self.sweep.status,
780 }
781 }
782
783 pub fn poll_saves(&mut self) {
784 while let Ok(SaveOutcome { target, result }) = self.saves.1.try_recv() {
785 if let SaveTarget::SweepResults(generation) = target
789 && matches!(result, SaveResult::Saved(_) | SaveResult::Downloaded(_))
790 {
791 self.results.mark_files_saved(generation);
792 }
793 *self.save_status(target) = Some(match result {
794 SaveResult::Saved(name) => format!("Saved {name}"),
795 SaveResult::Downloaded(name) => format!("Downloaded {name}"),
796 SaveResult::DownloadsStarted => DOWNLOADS_STARTED.to_owned(),
797 SaveResult::Failed(message) => format!("Save failed: {message}"),
798 SaveResult::Canceled => "Save canceled".to_owned(),
799 });
800 }
801 }
802
803 pub fn open_file(&self, target: OpenTarget) {
807 spawn_open(target, self.opens.0.clone(), self.egui_ctx.clone());
808 }
809
810 pub fn poll_opens(&mut self) {
811 while let Ok(OpenOutcome { target, result }) = self.opens.1.try_recv() {
812 match target {
813 OpenTarget::SweepSpec | OpenTarget::DesignTable | OpenTarget::OutputFolder => {
814 crate::ui::sweep::receive_open(self, target, result);
815 }
816 OpenTarget::Results => crate::ui::results::receive_open(self, result),
817 }
818 }
819 }
820
821 pub fn record(&mut self, snapshot: &Snapshot) {
823 if let Err(err) = self.recording.push(snapshot.tick, &snapshot.stats) {
824 self.export_status = Some(format!("Recording stopped: {err}"));
825 self.recording = Recording::Off;
826 }
827 }
828
829 pub fn stop_recording(&mut self) {
831 let to_tick = self.snapshot.as_ref().map_or(0, |snap| snap.tick);
832 match std::mem::replace(&mut self.recording, Recording::Off).stop(to_tick) {
833 Ok(recording) => self.recording = recording,
834 Err(err) => self.export_status = Some(format!("Recording failed: {err}")),
835 }
836 }
837
838 pub fn request_viewport_capture(&mut self, name: String) {
842 match crate::ui::export::image::start(self, name) {
843 Ok(pending) => {
844 self.export_status = Some("Capturing viewport".to_owned());
845 self.capture = Some(pending);
846 }
847 Err(err) => self.export_status = Some(format!("Capture failed: {err}")),
848 }
849 }
850
851 pub fn poll_capture(&mut self) {
852 let Some(pending) = &self.capture else {
853 return;
854 };
855 let Some(result) = pending.poll(&self.render_ctx.device) else {
856 return;
857 };
858 let name = pending.name.clone();
859 self.capture = None;
860 match result {
861 Ok(png) => self.save_as(SaveTarget::Export, &name, png),
862 Err(err) => self.export_status = Some(format!("Capture failed: {err}")),
863 }
864 }
865}
866
867#[cfg(test)]
868impl AppState {
869 pub fn headless(models: ModelSet, compute: bool) -> Option<Self> {
877 use henad_explore::testing::{TestDeviceRequest, headless_test_device};
878
879 let ctx = headless_test_device(&TestDeviceRequest::raised(models.gpu_needs()))?;
880 let runtime = ctx
881 .runtime_info()
882 .expect("a headless device carries its runtime info")
883 .clone();
884 let product = crate::options::AppOptions::new(
885 ModelSet::new(henad_core::build_info!()),
886 "Henad",
887 henad_core::build_info!(),
888 )
889 .cli_command("henad-cli")
890 .__official()
891 .product;
892 let gpu_ctx = compute.then(|| ctx.clone());
893 Some(Self::new(
894 egui::Context::default(),
895 models,
896 product,
897 ctx,
898 gpu_ctx,
899 runtime,
900 ))
901 }
902}
903
904pub fn default_values(entry: &ModelEntry) -> Vec<ParamValue> {
906 entry
907 .param_descriptors()
908 .iter()
909 .map(|descriptor| descriptor.kind.default_value())
910 .collect()
911}
912
913pub fn lookup_message(error: &ModelLookupError) -> String {
915 crate::ui::sweep::draft::capitalize(&error.to_string())
916}
917
918pub fn setup_message(error: &SetupError) -> String {
920 use crate::ui::sweep::draft::describe_error;
921 match error {
922 SetupError::Param(reason) => describe_error(reason),
923 other => describe_error(other),
924 }
925}
926
927#[cfg(test)]
928mod tests {
929 use henad_core::action::{Schedule, Scheduled};
930 use henad_core::explore::replay::Replay;
931 use henad_core::params::ParamValue;
932
933 use henad_compute::entry::ModelSet;
934 use henad_compute::fault::{BUILDING, Fault, STEPPING};
935 use henad_core::metadata::Backend;
936 use henad_explore::output::details::{ChoiceForm, params_by_id_json};
937
938 use super::{AppState, OpenAt, OpenedRun};
939 use crate::options::AppOpening;
940 use crate::ui::dock::Tab;
941 use crate::ui::export::metadata::run_details;
942
943 fn app_without_compute() -> Option<AppState> {
946 AppState::headless(henad_models::example_models(), false)
947 }
948
949 fn replay_of(model: &str) -> Replay {
951 Replay {
952 model: model.to_owned(),
953 params: Vec::new(),
954 seed: 1,
955 schedule: Schedule::default(),
956 ticks: 10,
957 label: "Sweep run 0".to_owned(),
958 }
959 }
960
961 #[test]
962 fn an_app_without_compute_hides_gpu_models_and_names_each_missing_one() {
963 let Some(mut app) = app_without_compute() else {
964 return;
965 };
966 let offered: Vec<&str> = app.offered_models().map(|entry| entry.id()).collect();
967 assert_eq!(
968 offered,
969 ["sir", "boids", "game_of_life", "ants", "virus_network", "team_assembly"]
970 );
971 assert_eq!(
972 app.selected_model.as_deref(),
973 Some("sir"),
974 "the first model that runs here"
975 );
976
977 assert_eq!(
978 app.open_run(replay_of("gpu_sir"), OpenAt::Start),
979 Err("Model 'gpu_sir' needs a GPU with compute support, and this device has none".to_owned())
980 );
981 assert_eq!(
982 app.open_run(replay_of("absent"), OpenAt::Start),
983 Err("This build does not include model 'absent'".to_owned())
984 );
985 assert_eq!(
986 app.selected_model.as_deref(),
987 Some("sir"),
988 "a refused run leaves the selection alone"
989 );
990 }
991
992 #[test]
993 fn a_build_follows_the_run_only_from_its_own_values() {
994 let schedule = Schedule::from_entries(vec![Scheduled {
995 index: 0,
996 id: "seed_outbreak".to_owned(),
997 tick: 50,
998 }]);
999 let params = vec![ParamValue::U32(64), ParamValue::F32(0.3)];
1000 let run = OpenedRun {
1001 replay: Replay {
1002 model: "sir".to_owned(),
1003 params: params.clone(),
1004 seed: 42,
1005 schedule: schedule.clone(),
1006 ticks: 300,
1007 label: "Sweep run 0: config 0, replicate 0".to_owned(),
1008 },
1009 modified: false,
1010 };
1011
1012 assert!(run.is_built_by(¶ms, Some(42), &schedule));
1013 assert!(
1014 !run.is_built_by(¶ms, None, &schedule),
1015 "the Default seed matched seed 42"
1016 );
1017 assert!(!run.is_built_by(¶ms, Some(43), &schedule), "another seed");
1018 assert!(!run.is_built_by(¶ms, Some(42), &Schedule::default()), "no schedule");
1019 let edited = [ParamValue::U32(64), ParamValue::F32(0.4)];
1020 assert!(!run.is_built_by(&edited, Some(42), &schedule), "an edited parameter");
1021 }
1022
1023 #[test]
1024 fn an_opened_setup_keeps_the_default_seed() {
1025 let Some(mut app) = app_without_compute() else {
1026 return;
1027 };
1028 let sir = app.models.get("sir").expect("the example models include sir").clone();
1029 let setup = sir
1030 .setup()
1031 .set("infection_rate", 0.4_f32)
1032 .and_then(|setup| setup.act_at("seed_outbreak", 20))
1033 .expect("a valid setup");
1034
1035 assert_eq!(app.open_setup(&setup, OpenAt::Start), Ok(()));
1036 assert_eq!(app.selected_model.as_deref(), Some("sir"));
1037 assert_eq!(app.param_values, setup.values());
1038 assert_eq!(app.schedule, *setup.schedule());
1039 assert_eq!(app.seed, None, "the default seed became a number");
1040 assert_eq!(app.seed_text, "", "the Seed field shows the Default hint");
1041 assert!(app.sim_thread.is_some(), "the setup built");
1042 assert_eq!(app.loaded_seed, None);
1043 assert_eq!(app.loaded_schedule, *setup.schedule());
1044 assert_eq!(app.opened_run, None, "a setup is no sweep run");
1045 assert_eq!(app.focus_request, Some(Tab::Viewport));
1046
1047 assert_eq!(app.open_setup(&setup.with_seed(7), OpenAt::Start), Ok(()));
1048 assert_eq!(app.seed, Some(7));
1049 assert_eq!(app.seed_text, "7");
1050 assert_eq!(app.loaded_seed, Some(7));
1051 }
1052
1053 #[test]
1056 fn picking_the_loaded_model_again_restores_what_it_runs_with() {
1057 let Some(mut app) = app_without_compute() else {
1058 return;
1059 };
1060 let sir = app.models.get("sir").expect("the example models include sir").clone();
1061 let setup = sir
1062 .setup()
1063 .set("infection_rate", 0.4_f32)
1064 .and_then(|setup| setup.act_at("seed_outbreak", 20))
1065 .expect("a valid setup")
1066 .with_seed(9);
1067 assert_eq!(app.open_setup(&setup, OpenAt::Start), Ok(()));
1068 let details = |app: &AppState| -> serde_json::Value {
1069 serde_json::from_str(&run_details(app)).expect("the run details are JSON")
1070 };
1071 let recorded = params_by_id_json(sir.param_descriptors(), setup.values(), ChoiceForm::Name);
1072
1073 app.select_model("boids");
1074 assert_eq!(
1075 details(&app)["params"],
1076 recorded,
1077 "another model's values under sir's ids"
1078 );
1079 app.select_model("sir");
1080 assert_eq!(app.param_values, setup.values());
1081 assert_eq!(app.pending_reload, vec![false; setup.values().len()]);
1082 assert_eq!((app.seed, app.seed_text.as_str()), (Some(9), "9"));
1083 assert_eq!(app.schedule, *setup.schedule());
1084 assert!(!app.seed_pending() && !app.schedule_pending());
1085 assert_eq!(details(&app)["params"], recorded);
1086 assert_eq!(details(&app)["params_match_running_model"], true);
1087
1088 let (index, live) = sir
1089 .param_descriptors()
1090 .iter()
1091 .enumerate()
1092 .find(|(_, descriptor)| descriptor.is_live())
1093 .expect("sir has a live parameter");
1094 let edited = match live.kind.default_value() {
1095 ParamValue::F32(_) => ParamValue::F32(0.25),
1096 other => other,
1097 };
1098 app.param_values[index] = edited.clone();
1099 assert!(app.send_live_param(index, edited.clone()));
1100 app.select_model("boids");
1101 assert!(
1102 !app.send_live_param(index, edited.clone()),
1103 "an edit of a model not selected"
1104 );
1105 app.select_model("sir");
1106 assert_eq!(
1107 app.param_values[index], edited,
1108 "the live edit came back with the model"
1109 );
1110 assert_eq!(app.loaded_values[index], edited);
1111 }
1112
1113 #[test]
1115 fn a_fault_names_the_model_it_stopped() {
1116 let Some(mut app) = app_without_compute() else {
1117 return;
1118 };
1119 let name = |app: &AppState, id: &str| app.models.get(id).map(|entry| entry.name().to_owned());
1120 app.reset_simulation();
1121 assert_eq!(app.loaded_model.as_deref(), Some("sir"));
1122 app.select_model("boids");
1123 app.report_fault(Fault::refused(STEPPING, "a test fault"));
1124 assert_eq!(
1125 app.fault.as_ref().and_then(|shown| shown.model.clone()),
1126 name(&app, "sir")
1127 );
1128 assert_eq!(app.loaded_model, None, "the faulted model is offloaded");
1129
1130 app.report_fault(Fault::refused(BUILDING, "a test fault"));
1131 assert_eq!(
1132 app.fault.as_ref().and_then(|shown| shown.model.clone()),
1133 name(&app, "boids"),
1134 "a build fault is the selected model's"
1135 );
1136 }
1137
1138 #[test]
1139 fn a_run_with_another_parameter_count_names_its_model() {
1140 let Some(mut app) = app_without_compute() else {
1141 return;
1142 };
1143 let declared = app
1144 .models
1145 .get("sir")
1146 .expect("the example models include sir")
1147 .param_descriptors()
1148 .len();
1149 assert_eq!(
1150 app.open_run(replay_of("sir"), OpenAt::Start),
1151 Err(format!("This run sets 0 parameters, but model 'sir' has {declared}"))
1152 );
1153 }
1154
1155 #[test]
1156 fn a_gpu_only_set_without_compute_opens_with_nothing_selected() {
1157 let mut models = ModelSet::new(henad_core::build_info!());
1158 for entry in &henad_models::example_models() {
1159 if entry.metadata().backend == Backend::Gpu {
1160 models.insert(entry.clone()).expect("example ids are unique");
1161 }
1162 }
1163 assert!(!models.is_empty());
1164 let Some(app) = AppState::headless(models, false) else {
1165 return;
1166 };
1167 assert_eq!(app.offered_models().count(), 0);
1168 assert_eq!(app.selected_model, None);
1169 assert!(app.selected_entry().is_none());
1170 assert!(app.param_values.is_empty());
1171 assert!(app.build_setup().is_none(), "Build has nothing to build");
1172 }
1173
1174 #[test]
1175 fn an_opening_of_a_hidden_gpu_model_opens_with_nothing_selected() {
1176 let Some(mut app) = app_without_compute() else {
1177 return;
1178 };
1179 let needs_gpu = "Model 'gpu_sir' needs a GPU with compute support, and this device has none";
1180 let gpu_sir = app
1181 .models
1182 .get("gpu_sir")
1183 .expect("the example models include gpu_sir")
1184 .clone();
1185
1186 app.open(AppOpening::Setup {
1187 setup: gpu_sir.setup(),
1188 open_at: OpenAt::Start,
1189 });
1190 assert_eq!(app.selected_model, None);
1191 assert!(app.param_values.is_empty());
1192 assert!(app.sim_thread.is_none());
1193 assert_eq!(
1194 app.opening_refusal.as_ref().map(|refusal| refusal.reason.as_str()),
1195 Some(needs_gpu)
1196 );
1197 assert_eq!(app.focus_request, Some(Tab::Model), "the Model panel shows the reason");
1198 assert_eq!(
1199 app.opening_refusal.as_ref().map(|refusal| refusal.lead),
1200 Some("Model not opened")
1201 );
1202
1203 let mut replay = replay_of("gpu_sir");
1204 replay.params = gpu_sir.setup().values().to_vec();
1205 app.open(AppOpening::Run {
1206 replay,
1207 open_at: OpenAt::Tick(5),
1208 });
1209 assert_eq!(app.selected_model, None);
1210 assert_eq!(
1211 app.opening_refusal.as_ref().map(|refusal| refusal.reason.as_str()),
1212 Some(needs_gpu)
1213 );
1214 assert_eq!(app.run_to_target, None);
1215 assert_eq!(
1216 app.opening_refusal.as_ref().map(|refusal| refusal.lead),
1217 Some("Run not opened")
1218 );
1219
1220 app.select_model("sir");
1221 assert_eq!(app.opening_refusal, None, "a selected model replaces the reason");
1222 }
1223}