From aa73fb1a73fa653ac339892cf559df9fba4e9f1c Mon Sep 17 00:00:00 2001 From: Markus Ast Date: Tue, 20 Apr 2021 13:38:46 +0200 Subject: [PATCH] initial support for DCS 2.7 clouds --- CHANGELOG.md | 8 + crates/datis-cmd/Cargo.toml | 2 +- crates/datis-core/Cargo.toml | 2 +- crates/datis-core/src/ipc.rs | 42 +-- crates/datis-core/src/station.rs | 451 +++++++++++++---------------- crates/datis-core/src/utils.rs | 4 + crates/datis-core/src/weather.rs | 353 +++++++++++++++++++++- crates/datis-module/Cargo.toml | 2 +- crates/datis-module/src/mission.rs | 38 +-- crates/radio-station/Cargo.toml | 2 +- crates/srs/Cargo.toml | 2 +- crates/win-tts/Cargo.toml | 2 +- mod/Mods/services/DATIS/entry.lua | 2 +- mod/Scripts/Hooks/datis-hook.lua | 46 +++ 14 files changed, 640 insertions(+), 316 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da40859..e48631f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +## [3.0.0-beta.1] - 2021-04-20 + +BREAKING: Requires DCS 2.7 from now on. + +### Added + +- Added support for DCS 2.7 cloud presets. + ## [2.2.2] - 2021-04-11 ### Changed diff --git a/crates/datis-cmd/Cargo.toml b/crates/datis-cmd/Cargo.toml index f7de498..283bf97 100644 --- a/crates/datis-cmd/Cargo.toml +++ b/crates/datis-cmd/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "datis-cmd" -version = "2.2.2" +version = "3.0.0-beta.1" authors = ["Markus Ast "] edition = "2018" diff --git a/crates/datis-core/Cargo.toml b/crates/datis-core/Cargo.toml index df49ffc..03d2f52 100644 --- a/crates/datis-core/Cargo.toml +++ b/crates/datis-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "datis-core" -version = "2.2.2" +version = "3.0.0-beta.1" authors = ["Markus Ast "] edition = "2018" diff --git a/crates/datis-core/src/ipc.rs b/crates/datis-core/src/ipc.rs index 21c3403..6f1016c 100644 --- a/crates/datis-core/src/ipc.rs +++ b/crates/datis-core/src/ipc.rs @@ -6,24 +6,21 @@ use crate::weather::{Clouds, WeatherInfo}; use dcs_module_ipc::Error; use serde::Deserialize; use serde_json::json; +use tokio::sync::Mutex; pub struct MissionRpcInner { ipc: dcs_module_ipc::IPC<()>, - clouds: Option, - fog_thickness: u32, // in m - fog_visibility: u32, // in m + clouds: Mutex>, } #[derive(Clone)] pub struct MissionRpc(Arc); impl MissionRpc { - pub fn new(clouds: Option, fog_thickness: u32, fog_visibility: u32) -> Self { + pub fn new() -> Self { MissionRpc(Arc::new(MissionRpcInner { ipc: dcs_module_ipc::IPC::new(), - clouds, - fog_thickness, - fog_visibility, + clouds: Mutex::new(None), })) } @@ -35,16 +32,11 @@ impl MissionRpc { wind_dir: f64, temp: f64, pressure: f64, + fog_thickness: f64, // in m + fog_visibility: f64, // in m + dust_density: u32, } - let clouds = self.0.clouds.clone(); - - let visibility = if self.0.fog_thickness > 200 { - Some(self.0.fog_visibility) - } else { - None - }; - let data: Data = self .0 .ipc @@ -72,18 +64,34 @@ impl MissionRpc { wind_dir += 360.0; } + let clouds = { + let mut clouds = self.0.clouds.lock().await; + if clouds.is_none() { + *clouds = Some(self.get_clouds().await?); + } + clouds.clone().unwrap() + }; + Ok(WeatherInfo { - clouds, - visibility, + clouds: Some(clouds), wind_speed: data.wind_speed, wind_dir, temperature: data.temp, pressure_qnh, pressure_qfe: data.pressure, + fog_thickness: data.fog_thickness, + fog_visibility: data.fog_visibility, + dust_density: data.dust_density, position: pos.clone(), }) } + pub async fn get_clouds(&self) -> Result { + let clouds: Clouds = self.0.ipc.request("get_clouds", None::<()>).await?; + + Ok(clouds) + } + pub async fn get_unit_position(&self, name: &str) -> Result { self.0 .ipc diff --git a/crates/datis-core/src/station.rs b/crates/datis-core/src/station.rs index 8e4355b..bc9034d 100644 --- a/crates/datis-core/src/station.rs +++ b/crates/datis-core/src/station.rs @@ -1,6 +1,6 @@ use crate::tts::TextToSpeechProvider; -use crate::utils::{m_to_ft, m_to_nm, pronounce_number, round}; -use crate::weather::{Clouds, WeatherInfo}; +use crate::utils::{m_to_nm, pronounce_number, round, round_hundreds}; +use crate::weather::WeatherInfo; pub use srs::message::{LatLngPosition, Position}; #[derive(Clone)] @@ -65,6 +65,134 @@ pub struct Report { const SPEAK_START_TAG: &str = "\n"; +#[inline] +fn break_(spoken: bool) -> &'static str { + #[cfg(not(test))] + if spoken { + "\n" + } else { + "" + } + #[cfg(test)] + if spoken { + "| " + } else { + "" + } +} + +fn wind_report(weather: &WeatherInfo, spoken: bool) -> Result { + let wind_dir = format!("{:0>3}", weather.wind_dir.round().to_string()); + Ok(format!( + "{} {} at {} knots. {}", + if spoken { + r#"Wind"# + } else { + "Wind" + }, + pronounce_number(wind_dir, spoken), + pronounce_number((weather.wind_speed * 1.94384).round(), spoken), // to knots + break_(spoken), + )) +} + +fn ceiling_report(weather: &WeatherInfo, alt: u32, spoken: bool) -> Result { + if let Some(ceiling) = weather.get_ceiling(alt) { + return Ok(format!( + "Ceiling {} {}. {}", + round_hundreds(ceiling.alt), + ceiling.coverage, + break_(spoken) + )); + } + + Ok(String::new()) +} + +fn weather_condition_report( + weather: &WeatherInfo, + alt: u32, + spoken: bool, +) -> Result { + let conditions = weather.get_weather_conditions(alt); + if conditions.is_empty() { + return Ok(String::new()); + } + + let ix_last = conditions.len(); + let mut result = String::new(); + for (i, c) in conditions.into_iter().enumerate() { + result += &format!( + "{}{}", + if i == 0 { + "" + } else if i == ix_last { + " and " + } else { + ", " + }, + c + ) + } + + Ok(format!("{}. {}", result, break_(spoken))) +} + +fn visibility_report( + weather: &WeatherInfo, + alt: u32, + spoken: bool, +) -> Result { + if let Some(visibility) = weather.get_visibility(alt) { + // 9260 m = 5 nm + if visibility < 9_260 { + let visibility = round(m_to_nm(f64::from(visibility)), 1); + return Ok(format!( + "Visibility {}. {}", + pronounce_number(visibility, spoken), + break_(spoken) + )); + } + } + + Ok(String::new()) +} + +fn temperatur_report(weather: &WeatherInfo, spoken: bool) -> Result { + Ok(format!( + "Temperature {} celcius. {}", + pronounce_number(round(weather.temperature, 1), spoken), + break_(spoken), + )) +} + +fn altimeter_report(weather: &WeatherInfo, spoken: bool) -> Result { + Ok(format!( + "ALTIMETER {}. {}", + // inHg, but using 0.02953 instead of 0.0002953 since we don't want to speak the + // DECIMAL here + pronounce_number((weather.pressure_qnh * 0.02953).round(), spoken), + break_(spoken), + )) +} + +fn hectopascal_report(weather: &WeatherInfo, spoken: bool) -> Result { + Ok(format!( + "{} hectopascal. {}", + pronounce_number((weather.pressure_qnh / 100.0).round(), spoken), // to hPA + break_(spoken), + )) +} + +fn qfe_report(weather: &WeatherInfo, spoken: bool) -> Result { + Ok(format!( + "QFE {} or {}. {}", + pronounce_number((weather.pressure_qfe * 0.02953).round(), spoken), // to inHg + pronounce_number((weather.pressure_qfe / 100.0).round(), spoken), // to hPA + break_(spoken), + )) +} + impl Station { #[cfg(feature = "ipc")] pub async fn generate_report(&self, report_nr: usize) -> Result, anyhow::Error> { @@ -98,8 +226,18 @@ impl Station { weather.wind_dir = (weather.wind_dir - declination).floor(); Ok(Some(Report { - textual: airfield.generate_report(report_nr, &weather, false)?, - spoken: airfield.generate_report(report_nr, &weather, true)?, + textual: airfield.generate_report( + report_nr, + &weather, + position.alt as u32, + false, + )?, + spoken: airfield.generate_report( + report_nr, + &weather, + position.alt as u32, + true, + )?, position, })) } @@ -191,8 +329,18 @@ impl Station { .context("failed to convert unit position to lat lng")?; Ok(Some(Report { - textual: weather.generate_report(report_nr, &weather_info, false)?, - spoken: weather.generate_report(report_nr, &weather_info, true)?, + textual: weather.generate_report( + report_nr, + &weather_info, + position.alt as u32, + false, + )?, + spoken: weather.generate_report( + report_nr, + &weather_info, + position.alt as u32, + true, + )?, position, })) } @@ -204,19 +352,19 @@ impl Station { pub async fn generate_report(&self, report_nr: usize) -> Result, anyhow::Error> { let weather_info = WeatherInfo { clouds: None, - visibility: None, wind_speed: 2.5, wind_dir: (330.0f64).to_radians(), temperature: 22.0, pressure_qnh: 101_500.0, pressure_qfe: 101_500.0, position: Position::default(), + ..Default::default() }; match &self.transmitter { Transmitter::Airfield(airfield) => Ok(Some(Report { - textual: airfield.generate_report(report_nr, &weather_info, false)?, - spoken: airfield.generate_report(report_nr, &weather_info, true)?, + textual: airfield.generate_report(report_nr, &weather_info, 0, false)?, + spoken: airfield.generate_report(report_nr, &weather_info, 0, true)?, position: LatLngPosition::default(), })), Transmitter::Carrier(unit) => { @@ -238,8 +386,8 @@ impl Station { position: LatLngPosition::default(), })), Transmitter::Weather(weather) => Ok(Some(Report { - textual: weather.generate_report(report_nr, &weather_info, false)?, - spoken: weather.generate_report(report_nr, &weather_info, true)?, + textual: weather.generate_report(report_nr, &weather_info, 0, false)?, + spoken: weather.generate_report(report_nr, &weather_info, 0, true)?, position: LatLngPosition::default(), })), } @@ -274,13 +422,9 @@ impl Airfield { &self, report_nr: usize, weather: &WeatherInfo, + alt: u32, spoken: bool, ) -> Result { - #[cfg(not(test))] - let _break = if spoken { "\n" } else { "" }; - #[cfg(test)] - let _break = if spoken { "| " } else { "" }; - let mut report = if spoken { SPEAK_START_TAG } else { "" }.to_string(); let information_num = if let Some(ltr_override) = self.info_ltr_override { @@ -292,89 +436,36 @@ impl Airfield { report += &format!( "This is {} information {}. {}", - self.name, information_letter, _break + self.name, + information_letter, + break_(spoken) ); if let Some(rwy) = self.get_active_runway(weather.wind_dir) { let rwy = pronounce_number(rwy, spoken); - report += &format!("Runway in use is {}. {}", rwy, _break); + report += &format!("Runway in use is {}. {}", rwy, break_(spoken)); } else { log::error!("Could not find active runway for {}", self.name); } - let wind_dir = format!("{:0>3}", weather.wind_dir.round().to_string()); - report += &format!( - "{} {} at {} knots. {}", - if spoken { - r#"Wind"# - } else { - "Wind" - }, - pronounce_number(wind_dir, spoken), - pronounce_number((weather.wind_speed * 1.94384).round(), spoken), // to knots - _break, - ); - - let mut visibility = None; - if let Some(ref clouds_report) = weather.clouds { - if self.position.alt > clouds_report.base as f64 - && self.position.alt < clouds_report.base as f64 + clouds_report.thickness as f64 - && clouds_report.density >= 9 - { - // the airport is within completely condensed clouds - visibility = Some(0); - } - } - - if let Some(visibility) = visibility.or(weather.visibility) { - // 9260 m = 5 nm - if visibility < 9_260 { - report += &format!("{}. {}", get_visibility_report(visibility, spoken), _break); - } - } - - if let Some(clouds_report) = weather - .clouds - .as_ref() - .and_then(|clouds| get_clouds_report(clouds, spoken)) - { - report += &format!("{}. {}", clouds_report, _break); - } - - report += &format!( - "Temperature {} celcius. {}", - pronounce_number(round(weather.temperature, 1), spoken), - _break, - ); - - report += &format!( - "ALTIMETER {}. {}", - // inHg, but using 0.02953 instead of 0.0002953 since we don't want to speak the - // DECIMAL here - pronounce_number((weather.pressure_qnh * 0.02953).round(), spoken), - _break, - ); - if let Some(traffic_freq) = self.traffic_freq { report += &format!( "Traffic frequency {}. {}", pronounce_number(round(traffic_freq as f64 / 1_000_000.0, 3), spoken), - _break + break_(spoken) ); } - report += &format!("REMARKS. {}", _break,); - report += &format!( - "{} hectopascal. {}", - pronounce_number((weather.pressure_qnh / 100.0).round(), spoken), // to hPA - _break, - ); - report += &format!( - "QFE {} or {}. {}", - pronounce_number((weather.pressure_qfe * 0.02953).round(), spoken), // to inHg - pronounce_number((weather.pressure_qfe / 100.0).round(), spoken), // to hPA - _break, - ); + report += &wind_report(weather, spoken)?; + report += &ceiling_report(weather, alt, spoken)?; + report += &weather_condition_report(weather, alt, spoken)?; + report += &visibility_report(weather, alt, spoken)?; + report += &temperatur_report(weather, spoken)?; + report += &altimeter_report(weather, spoken)?; + + report += &format!("REMARKS. {}", break_(spoken),); + report += &hectopascal_report(weather, spoken)?; + report += &qfe_report(weather, spoken)?; report += &format!("End information {}.", information_letter); @@ -394,14 +485,9 @@ impl Carrier { mission_hour: u16, spoken: bool, ) -> Result { - #[cfg(not(test))] - let _break = if spoken { "\n" } else { "" }; - #[cfg(test)] - let _break = if spoken { "| " } else { "" }; - let mut report = if spoken { SPEAK_START_TAG } else { "" }.to_string(); - report += &format!("99, {}", _break); + report += &format!("99, {}", break_(spoken)); let wind_dir = format!("{:0>3}", weather.wind_dir.round().to_string()); report += &format!( @@ -414,7 +500,7 @@ impl Carrier { }, pronounce_number(wind_dir, spoken), pronounce_number((weather.wind_speed * 1.94384).round(), spoken), - _break, + break_(spoken), ); report += &format!( @@ -422,23 +508,24 @@ impl Carrier { // inHg, but using 0.02953 instead of 0.0002953 since we don't want to speak the // DECIMAL here pronounce_number((weather.pressure_qnh * 0.02953).round(), spoken), - _break, + break_(spoken), ); + let alt = 21; // carrier deck alt in m + // Case 1: daytime, ceiling >= 3000ft; visibility distance >= 5nm // Case 2: daytime, ceiling >= 1000ft; visibility distance >= 5nm // Case 3: nighttime or daytime, ceiling < 1000ft and visibility distance <= 5nm let mut case = 1; - if let Some(ceiling) = weather.clouds.as_ref().map(|clouds| clouds.base) { - let ft = m_to_ft(ceiling as f64); - if ft < 1_000.0 { + if let Some(ceiling) = weather.get_ceiling(alt) { + if ceiling.alt < 1_000.0 { case = 3; - } else if ft < 3_000.0 { + } else if ceiling.alt < 3_000.0 { case = 2; } } - if let Some(visibility) = weather.visibility { + if let Some(visibility) = weather.get_visibility(alt) { // 9260 m = 5 nm if visibility < 9_260 { case = 3; @@ -451,7 +538,7 @@ impl Carrier { case = 3; } - report += &format!("CASE {}, {}", case, _break,); + report += &format!("CASE {}, {}", case, break_(spoken),); let brc = heading; let mut fh = heading - 9; // 9 -> 9deg angled deck @@ -460,13 +547,13 @@ impl Carrier { } let brc = format!("{:0>3}", brc); - report += &format!("BRC {}, {}", pronounce_number(brc, spoken), _break); + report += &format!("BRC {}, {}", pronounce_number(brc, spoken), break_(spoken)); let fh = format!("{:0>3}", fh); report += &format!( "expected final heading {}, {}", pronounce_number(fh, spoken), - _break, + break_(spoken), ); report += "report initial."; @@ -484,13 +571,9 @@ impl WeatherTransmitter { &self, report_nr: usize, weather: &WeatherInfo, + alt: u32, spoken: bool, ) -> Result { - #[cfg(not(test))] - let _break = if spoken { "\n" } else { "" }; - #[cfg(test)] - let _break = if spoken { "| " } else { "" }; - let information_num = if let Some(ltr_override) = self.info_ltr_override { (ltr_override.to_ascii_uppercase() as usize) - 65 } else { @@ -501,71 +584,21 @@ impl WeatherTransmitter { report += &format!( "This is weather station {} information {}. {}", - self.name, information_letter, _break - ); - - // TODO: reduce redundancy with ATIS report generation - - let wind_dir = format!("{:0>3}", weather.wind_dir.round().to_string()); - report += &format!( - "Wind {} at {} knots. {}", - pronounce_number(wind_dir, spoken), - pronounce_number((weather.wind_speed * 1.94384).round(), spoken), // to knots - _break, + self.name, + information_letter, + break_(spoken) ); - let mut visibility = None; - if let Some(ref clouds_report) = weather.clouds { - if weather.position.alt > clouds_report.base as f64 - && weather.position.alt < clouds_report.base as f64 + clouds_report.thickness as f64 - && clouds_report.density >= 9 - { - // the airport is within completely condensed clouds - visibility = Some(0); - } - } - - if let Some(visibility) = visibility.or(weather.visibility) { - // 9260 m = 5 nm - if visibility < 9_260 { - report += &format!("{}. {}", get_visibility_report(visibility, spoken), _break); - } - } - - if let Some(clouds_report) = weather - .clouds - .as_ref() - .and_then(|clouds| get_clouds_report(clouds, spoken)) - { - report += &format!("{}. {}", clouds_report, _break); - } - - report += &format!( - "Temperature {} celcius. {}", - pronounce_number(round(weather.temperature, 1), spoken), - _break, - ); + report += &wind_report(weather, spoken)?; + report += &ceiling_report(weather, alt, spoken)?; + report += &weather_condition_report(weather, alt, spoken)?; + report += &visibility_report(weather, alt, spoken)?; + report += &temperatur_report(weather, spoken)?; + report += &altimeter_report(weather, spoken)?; - report += &format!( - "ALTIMETER {}. {}", - // inHg, but using 0.02953 instead of 0.0002953 since we don't want to speak the - // DECIMAL here - pronounce_number((weather.pressure_qnh * 0.02953).round(), spoken), - _break, - ); - - report += &format!("REMARKS. {}", _break,); - report += &format!( - "{} hectopascal. {}", - pronounce_number((weather.pressure_qnh / 100.0).round(), spoken), // to hPA - _break, - ); - report += &format!( - "QFE {} or {}. {}", - pronounce_number((weather.pressure_qfe * 0.02953).round(), spoken), // to inHg - pronounce_number((weather.pressure_qfe / 100.0).round(), spoken), // to hPA - _break, - ); + report += &format!("REMARKS. {}", break_(spoken),); + report += &hectopascal_report(weather, spoken)?; + report += &qfe_report(weather, spoken)?; report += &format!("End information {}.", information_letter); @@ -577,40 +610,6 @@ impl WeatherTransmitter { } } -fn get_visibility_report(visibility: u32, spoken: bool) -> String { - let visibility = round(m_to_nm(f64::from(visibility)), 1); - format!("Visibility {}", pronounce_number(visibility, spoken)) -} - -fn get_clouds_report(clouds: &Clouds, spoken: bool) -> Option { - let density = match clouds.density { - 2..=5 => Some("few"), - 6..=7 => Some("scattered"), - 8 => Some("broken"), - 9..=10 => Some("overcast"), - _ => None, - }; - if let Some(density) = density { - let mut report = String::new(); - // convert m to ft, round to lowest 500ft increment and shortened (e.g. 17500 -> 175) - let base = m_to_ft(f64::from(clouds.base)).round() as u32; - let base = (base - (base % 500)) / 100; - report += &format!( - "Cloud conditions {} {}", - density, - pronounce_number(base, spoken) - ); - match clouds.iprecptns { - 1 => report += ", rain", - 2 => report += ", rain and thunderstorm", - _ => {} - } - Some(report) - } else { - None - } -} - fn escape_xml(s: &str) -> String { s.replace('<', "<").replace('&', "&") } @@ -672,8 +671,8 @@ mod test { }; let report = station.generate_report(26).await.unwrap().unwrap(); - assert_eq!(report.spoken, "\nThis is Kutaisi information Alpha. | Runway in use is ZERO 4. | Wind ZERO ZERO 6 at 5 knots. | Temperature 2 2 celcius. | ALTIMETER 2 NINER NINER 7. | Traffic frequency 2 4 NINER DECIMAL 5. | REMARKS. | 1 ZERO 1 5 hectopascal. | QFE 2 NINER NINER 7 or 1 ZERO 1 5. | End information Alpha.\n"); - assert_eq!(report.textual, "This is Kutaisi information Alpha. Runway in use is 04. Wind 006 at 5 knots. Temperature 22 celcius. ALTIMETER 2997. Traffic frequency 249.5. REMARKS. 1015 hectopascal. QFE 2997 or 1015. End information Alpha."); + assert_eq!(report.spoken, "\nThis is Kutaisi information Alpha. | Runway in use is ZERO 4. | Traffic frequency 2 4 NINER DECIMAL 5. | Wind ZERO ZERO 6 at 5 knots. | Temperature 2 2 celcius. | ALTIMETER 2 NINER NINER 7. | REMARKS. | 1 ZERO 1 5 hectopascal. | QFE 2 NINER NINER 7 or 1 ZERO 1 5. | End information Alpha.\n"); + assert_eq!(report.textual, "This is Kutaisi information Alpha. Runway in use is 04. Traffic frequency 249.5. Wind 006 at 5 knots. Temperature 22 celcius. ALTIMETER 2997. REMARKS. 1015 hectopascal. QFE 2997 or 1015. End information Alpha."); } #[tokio::test] @@ -694,8 +693,8 @@ mod test { }; let report = station.generate_report(26).await.unwrap().unwrap(); - assert_eq!(report.spoken, "\nThis is Kutaisi information Papa. | Runway in use is ZERO 4. | Wind ZERO ZERO 6 at 5 knots. | Temperature 2 2 celcius. | ALTIMETER 2 NINER NINER 7. | Traffic frequency 2 4 NINER DECIMAL 5. | REMARKS. | 1 ZERO 1 5 hectopascal. | QFE 2 NINER NINER 7 or 1 ZERO 1 5. | End information Papa.\n"); - assert_eq!(report.textual, "This is Kutaisi information Papa. Runway in use is 04. Wind 006 at 5 knots. Temperature 22 celcius. ALTIMETER 2997. Traffic frequency 249.5. REMARKS. 1015 hectopascal. QFE 2997 or 1015. End information Papa."); + assert_eq!(report.spoken, "\nThis is Kutaisi information Papa. | Runway in use is ZERO 4. | Traffic frequency 2 4 NINER DECIMAL 5. | Wind ZERO ZERO 6 at 5 knots. | Temperature 2 2 celcius. | ALTIMETER 2 NINER NINER 7. | REMARKS. | 1 ZERO 1 5 hectopascal. | QFE 2 NINER NINER 7 or 1 ZERO 1 5. | End information Papa.\n"); + assert_eq!(report.textual, "This is Kutaisi information Papa. Runway in use is 04. Traffic frequency 249.5. Wind 006 at 5 knots. Temperature 22 celcius. ALTIMETER 2997. REMARKS. 1015 hectopascal. QFE 2997 or 1015. End information Papa."); } #[tokio::test] @@ -716,8 +715,8 @@ mod test { }; let report = station.generate_report(26).await.unwrap().unwrap(); - assert_eq!(report.spoken, "\nThis is Kutaisi information Quebec. | Runway in use is ZERO 4. | Wind ZERO ZERO 6 at 5 knots. | Temperature 2 2 celcius. | ALTIMETER 2 NINER NINER 7. | Traffic frequency 2 4 NINER DECIMAL 5. | REMARKS. | 1 ZERO 1 5 hectopascal. | QFE 2 NINER NINER 7 or 1 ZERO 1 5. | End information Quebec.\n"); - assert_eq!(report.textual, "This is Kutaisi information Quebec. Runway in use is 04. Wind 006 at 5 knots. Temperature 22 celcius. ALTIMETER 2997. Traffic frequency 249.5. REMARKS. 1015 hectopascal. QFE 2997 or 1015. End information Quebec."); + assert_eq!(report.spoken, "\nThis is Kutaisi information Quebec. | Runway in use is ZERO 4. | Traffic frequency 2 4 NINER DECIMAL 5. | Wind ZERO ZERO 6 at 5 knots. | Temperature 2 2 celcius. | ALTIMETER 2 NINER NINER 7. | REMARKS. | 1 ZERO 1 5 hectopascal. | QFE 2 NINER NINER 7 or 1 ZERO 1 5. | End information Quebec.\n"); + assert_eq!(report.textual, "This is Kutaisi information Quebec. Runway in use is 04. Traffic frequency 249.5. Wind 006 at 5 knots. Temperature 22 celcius. ALTIMETER 2997. REMARKS. 1015 hectopascal. QFE 2997 or 1015. End information Quebec."); } #[test] @@ -731,42 +730,6 @@ mod test { assert_eq!(phonetic_alphabet::lookup(51), "Zulu"); } - #[test] - fn test_visibility_report() { - assert_eq!(get_visibility_report(6_000, true), "Visibility 3 DECIMAL 2"); - } - - #[test] - fn test_clouds_report() { - fn create_clouds_report(base: u32, density: u32, iprecptns: u32) -> Option { - let clouds = Clouds { - base, - density, - thickness: 0, - iprecptns, - }; - get_clouds_report(&clouds, true) - } - - assert_eq!(create_clouds_report(8400, 1, 0), None); - assert_eq!( - create_clouds_report(8400, 2, 0), - Some("Cloud conditions few 2 7 5".to_string()) - ); - assert_eq!( - create_clouds_report(8400, 2, 0), - Some("Cloud conditions few 2 7 5".to_string()) - ); - assert_eq!( - create_clouds_report(8500, 6, 1), - Some("Cloud conditions scattered 2 7 5, rain".to_string()) - ); - assert_eq!( - create_clouds_report(8500, 10, 2), - Some("Cloud conditions overcast 2 7 5, rain and thunderstorm".to_string()) - ); - } - #[tokio::test] async fn test_carrier_report() { let station = Station { @@ -824,7 +787,7 @@ mod test { }; let report = station.generate_report(26).await.unwrap().unwrap(); - assert_eq!(report.spoken, "\nThis is weather station Mountain Range information Papa. | Wind ZERO ZERO 6 at 5 knots. | Temperature 2 2 celcius. | ALTIMETER 2 NINER NINER 7. | REMARKS. | 1 ZERO 1 5 hectopascal. | QFE 2 NINER NINER 7 or 1 ZERO 1 5. | End information Papa.\n"); + assert_eq!(report.spoken, "\nThis is weather station Mountain Range information Papa. | Wind ZERO ZERO 6 at 5 knots. | Temperature 2 2 celcius. | ALTIMETER 2 NINER NINER 7. | REMARKS. | 1 ZERO 1 5 hectopascal. | QFE 2 NINER NINER 7 or 1 ZERO 1 5. | End information Papa.\n"); assert_eq!(report.textual, "This is weather station Mountain Range information Papa. Wind 006 at 5 knots. Temperature 22 celcius. ALTIMETER 2997. REMARKS. 1015 hectopascal. QFE 2997 or 1015. End information Papa."); } } diff --git a/crates/datis-core/src/utils.rs b/crates/datis-core/src/utils.rs index 50cffb0..1ce4db5 100644 --- a/crates/datis-core/src/utils.rs +++ b/crates/datis-core/src/utils.rs @@ -6,6 +6,10 @@ pub fn round(n: f64, max_decimal_places: i32) -> f64 { (n * m).round() / m } +pub fn round_hundreds(n: f64) -> f64 { + (n / 100.0).round() * 100.0 +} + static PHONETIC_NUMBERS: &[&str] = &["ZERO", "1", "2", "3", "4", "5", "6", "7", "8", "NINER"]; pub fn pronounce_number(n: S, pronounce: bool) -> String diff --git a/crates/datis-core/src/weather.rs b/crates/datis-core/src/weather.rs index adcf9ef..483d6f1 100644 --- a/crates/datis-core/src/weather.rs +++ b/crates/datis-core/src/weather.rs @@ -1,21 +1,352 @@ use crate::station::Position; +use crate::utils::m_to_ft; +use serde::Deserialize; #[derive(Debug, PartialEq, Clone, Default)] -pub struct Clouds { +pub struct WeatherInfo { + pub clouds: Option, + pub wind_speed: f64, // in m/s + pub wind_dir: f64, // in degrees (the direction the wind is coming from) + pub temperature: f64, // in °C + pub pressure_qnh: f64, // in N/m2 + pub pressure_qfe: f64, // in N/m2 + pub fog_thickness: f64, // in m + pub fog_visibility: f64, // in m + pub dust_density: u32, + pub position: Position, +} + +#[derive(Debug, PartialEq, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Clouds { + New(NewClouds), + Old(OldClouds), +} + +#[derive(Debug, PartialEq, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NewClouds { + pub base: u32, // in m + pub preset: CloudPreset, +} + +#[derive(Debug, PartialEq, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloudPreset { + pub precipitation_power: f64, + pub preset_alt_min: u32, // in m + pub preset_alt_max: u32, // in m + pub layers: Vec, +} + +#[derive(Debug, PartialEq, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NewCloudLayer { + altitude_min: u32, // in m + altitude_max: u32, // in m + coverage: f64, +} + +#[derive(Debug, PartialEq, Clone, Default, Deserialize)] +pub struct OldClouds { pub base: u32, // in m pub density: u32, pub thickness: u32, pub iprecptns: u32, } -#[derive(Debug, PartialEq, Clone, Default)] -pub struct WeatherInfo { - pub clouds: Option, - pub visibility: Option, // in m - pub wind_speed: f64, // in m/s - pub wind_dir: f64, // in degrees (the direction the wind is coming from) - pub temperature: f64, // in °C - pub pressure_qnh: f64, // in N/m2 - pub pressure_qfe: f64, // in N/m2 - pub position: Position, +impl WeatherInfo { + /// in m + pub fn get_visibility(&self, alt: u32) -> Option { + let clouds_vis = self.clouds.as_ref().and_then(|c| c.get_visibility(alt)); + let dust_vis = self.get_dust_storm_visibility(alt); + let fog_vis = self.get_fog_visibility(alt); + let vis = vec![clouds_vis, dust_vis, fog_vis] + .into_iter() + .filter_map(|e| e) + .min(); + // Visibility below 50m is considered as ZERO + vis.map(|v| if v < 50 { 0 } else { v }) + } + + /// in m + fn get_dust_storm_visibility(&self, alt: u32) -> Option { + if self.dust_density == 0 || alt >= 50 { + return None; + } + + // The multiplier of 4 was derived by manual testing the resulting visibility. + return Some(self.dust_density * 4); + } + + /// in m + fn get_fog_visibility(&self, alt: u32) -> Option { + if self.fog_visibility == 0.0 || alt as f64 > self.fog_thickness { + return None; + } + + return Some(self.fog_visibility.round() as u32); + } + + /// in ft + pub fn get_ceiling(&self, alt: u32) -> Option { + for layer in self.get_cloud_layers() { + if (layer.altitude_min..=layer.altitude_max).contains(&alt) { + return None; + } + + if layer.altitude_min > alt + && matches!( + layer.coverage, + CloudCoverage::Broken | CloudCoverage::Overcast + ) + { + return Some(Ceiling { + alt: m_to_ft(layer.altitude_min as f64), + coverage: layer.coverage, + }); + } + } + + None + } + + pub fn get_cloud_layers(&self) -> Vec { + self.clouds + .as_ref() + .map(|c| c.get_cloud_layers()) + .unwrap_or_default() + } + + pub fn get_weather_conditions(&self, alt: u32) -> Vec { + let mut kind = self + .clouds + .as_ref() + .map(|c| c.get_weather_conditions()) + .unwrap_or_default(); + if self.get_dust_storm_visibility(alt).is_some() { + kind.push(WeatherCondition::DustStorm); + } + if self.get_fog_visibility(alt).is_some() { + kind.push(WeatherCondition::Fog); + } + kind + } +} + +pub struct Ceiling { + /// in ft + pub alt: f64, + pub coverage: CloudCoverage, +} + +#[derive(Debug, Clone, Copy)] +pub enum CloudCoverage { + Clear, + Few, + Scattered, + Broken, + Overcast, +} + +#[derive(Debug, Clone, Copy)] +pub enum WeatherCondition { + SlightRain, + Rain, + HeavyRain, + Thunderstorm, + Fog, + DustStorm, +} + +pub struct CloudLayer { + coverage: CloudCoverage, + altitude_min: u32, + altitude_max: u32, +} + +impl Clouds { + pub fn get_cloud_layers(&self) -> Vec { + match self { + Clouds::New(clouds) => clouds.get_cloud_layers(), + Clouds::Old(clouds) => clouds.get_cloud_layers(), + } + } + + pub fn get_weather_conditions(&self) -> Vec { + match self { + Clouds::New(clouds) => clouds.get_weather_conditions(), + Clouds::Old(clouds) => clouds.get_weather_conditions(), + } + } + + /// in meters + pub fn get_visibility(&self, alt: u32) -> Option { + for layer in self.get_cloud_layers() { + if alt >= layer.altitude_min + && alt <= layer.altitude_max + && matches!( + layer.coverage, + CloudCoverage::Scattered | CloudCoverage::Broken | CloudCoverage::Overcast + ) + { + return Some(0); + } + } + + match self { + Clouds::New(clouds) => clouds.get_visibility(), + Clouds::Old(clouds) => clouds.get_visibility(), + } + } +} + +impl NewClouds { + pub fn get_cloud_layers(&self) -> Vec { + let diff = match self.preset.layers.first() { + Some(first) => first.altitude_min - self.base, + None => return Vec::new(), + }; + + self.preset + .layers + .iter() + .map(|layer| CloudLayer { + coverage: match layer.coverage { + x if (0.0..0.3).contains(&x) => CloudCoverage::Clear, + x if (0.3..0.5).contains(&x) => CloudCoverage::Few, + x if (0.5..0.6).contains(&x) => CloudCoverage::Scattered, + x if (0.6..0.9).contains(&x) => CloudCoverage::Broken, + x if (0.9..f64::MAX).contains(&x) => CloudCoverage::Overcast, + _ => CloudCoverage::Clear, // unreachable + }, + altitude_min: layer.altitude_min + diff, + altitude_max: layer.altitude_max + diff, + }) + .collect() + } + + pub fn get_weather_conditions(&self) -> Vec { + if self.preset.precipitation_power <= 0.0 { + return vec![]; + } + + match self.preset.precipitation_power { + x if (0.0..0.5).contains(&x) => vec![WeatherCondition::SlightRain], + x if (0.5..0.8).contains(&x) => vec![WeatherCondition::Rain], + x if (0.8..f64::MAX).contains(&x) => vec![WeatherCondition::HeavyRain], + _ => vec![], // unreachable + } + } + + pub fn get_visibility(&self) -> Option { + if self.preset.precipitation_power <= 0.0 { + return None; + } + + match self.preset.precipitation_power { + x if (0.0..0.3).contains(&x) => Some(5_000), + x if (0.3..0.5).contains(&x) => Some(4_000), + x if (0.5..0.6).contains(&x) => Some(3_000), + x if (0.6..0.7).contains(&x) => Some(2_500), + x if (0.7..0.8).contains(&x) => Some(2_000), + x if (0.8..0.9).contains(&x) => Some(1_500), + x if (0.9..0.97).contains(&x) => Some(1_000), + x if (1.0..f64::MAX).contains(&x) => Some(700), + _ => None, // unreachable + } + } +} + +impl OldClouds { + pub fn get_cloud_layers(&self) -> Vec { + vec![CloudLayer { + coverage: match self.density { + x if (0..2).contains(&x) => CloudCoverage::Clear, + x if (2..4).contains(&x) => CloudCoverage::Few, + x if (4..6).contains(&x) => CloudCoverage::Scattered, + x if (6..9).contains(&x) => CloudCoverage::Broken, + x if (9..u32::MAX).contains(&x) => CloudCoverage::Overcast, + _ => CloudCoverage::Clear, // unreachable + }, + altitude_min: if self.base < 1_000 { + 0 + } else { + self.base - 200 + }, + altitude_max: self.base + self.thickness - 200, + }] + } + + pub fn get_weather_conditions(&self) -> Vec { + match self.iprecptns { + 1 => vec![WeatherCondition::Rain], + 2 => vec![WeatherCondition::Rain, WeatherCondition::Thunderstorm], + _ => vec![], + } + } + + pub fn get_visibility(&self) -> Option { + match self.iprecptns { + 1 => Some(7_400), + 2 => Some(1_200), + _ => None, + } + } +} + +impl CloudCoverage { + pub fn to_metar(&self) -> &str { + match self { + CloudCoverage::Clear => "CLR", + CloudCoverage::Few => "FEW", + CloudCoverage::Scattered => "SCT", + CloudCoverage::Broken => "BKN", + CloudCoverage::Overcast => "OVC", + } + } +} + +impl std::fmt::Display for CloudCoverage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + CloudCoverage::Clear => "Clear", + CloudCoverage::Few => "Few", + CloudCoverage::Scattered => "Scattered", + CloudCoverage::Broken => "Broken", + CloudCoverage::Overcast => "Overcast", + }) + } +} + +impl WeatherCondition { + pub fn to_metar(&self) -> &str { + match self { + WeatherCondition::SlightRain => "-RA", + WeatherCondition::Rain => "RA", + WeatherCondition::HeavyRain => "+RA", + WeatherCondition::Thunderstorm => "TS", + WeatherCondition::Fog => "FG", + WeatherCondition::DustStorm => "DS", + } + } +} + +impl std::fmt::Display for WeatherCondition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + WeatherCondition::SlightRain => "Slight Rain", + WeatherCondition::Rain => "Rain", + WeatherCondition::HeavyRain => "Heavy Rain", + WeatherCondition::Thunderstorm => "Thunderstorm", + WeatherCondition::Fog => "Fog", + WeatherCondition::DustStorm => "Dust Storm", + }) + } +} + +impl Default for Clouds { + fn default() -> Self { + Clouds::Old(OldClouds::default()) + } } diff --git a/crates/datis-module/Cargo.toml b/crates/datis-module/Cargo.toml index 0edc76d..791f41d 100644 --- a/crates/datis-module/Cargo.toml +++ b/crates/datis-module/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "datis" -version = "2.2.2" +version = "3.0.0-beta.1" authors = ["Markus Ast "] edition = "2018" diff --git a/crates/datis-module/src/mission.rs b/crates/datis-module/src/mission.rs index 60217bc..f91a49d 100644 --- a/crates/datis-module/src/mission.rs +++ b/crates/datis-module/src/mission.rs @@ -5,7 +5,6 @@ use datis_core::extract::*; use datis_core::ipc::*; use datis_core::station::*; use datis_core::tts::TextToSpeechProvider; -use datis_core::weather::Clouds; use mlua::prelude::{Lua, LuaTable, LuaTableExt}; use rand::Rng; @@ -158,41 +157,6 @@ pub fn extract(lua: &Lua) -> Result { } } - // extract the current mission's weather kind and static weather configuration - let (clouds, fog_thickness, fog_visibility) = { - // read `_current_mission.mission.weather` - let current_mission: LuaTable<'_> = lua.globals().get("_current_mission")?; - let mission: LuaTable<'_> = current_mission.get("mission")?; - let weather: LuaTable<'_> = mission.get("weather")?; - - // read `_current_mission.mission.weather.atmosphere_type` - let atmosphere_type: f64 = weather.get("atmosphere_type")?; - let is_dynamic = atmosphere_type != 0.0; - - let clouds = { - if is_dynamic { - None - } else { - let clouds: LuaTable<'_> = weather.get("clouds")?; - Some(Clouds { - base: clouds.get("base")?, - density: clouds.get("density")?, - thickness: clouds.get("thickness")?, - iprecptns: clouds.get("iprecptns")?, - }) - } - }; - - // Note: `weather.visibility` is always the same, which is why we cannot use it here - // and use the fog instead to derive some kind of visibility - - let fog: LuaTable<'_> = weather.get("fog")?; - let fog_thickness: u32 = fog.get("thickness")?; - let fog_visibility: u32 = fog.get("visibility")?; - - (clouds, fog_thickness, fog_visibility) - }; - // YOLO initialize the atmosphere, because DCS initializes it only after hitting the // "Briefing" button, which is something most of the time not done for "dedicated" servers { @@ -206,7 +170,7 @@ pub fn extract(lua: &Lua) -> Result { } // initialize the dynamic weather component - let ipc = MissionRpc::new(clouds, fog_thickness, fog_visibility); + let ipc = MissionRpc::new(); let default_voice = match TextToSpeechProvider::from_str(&options.default_voice) { Ok(default_voice) => default_voice, diff --git a/crates/radio-station/Cargo.toml b/crates/radio-station/Cargo.toml index 727cd5c..6d34334 100644 --- a/crates/radio-station/Cargo.toml +++ b/crates/radio-station/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dcs-radio-station" -version = "2.2.2" +version = "3.0.0-beta.1" authors = ["Markus Ast "] edition = "2018" diff --git a/crates/srs/Cargo.toml b/crates/srs/Cargo.toml index e64726b..5ca2eb0 100644 --- a/crates/srs/Cargo.toml +++ b/crates/srs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "srs" -version = "2.2.2" +version = "3.0.0-beta.1" authors = ["rkusa"] edition = "2018" diff --git a/crates/win-tts/Cargo.toml b/crates/win-tts/Cargo.toml index fd1cf48..d104c3c 100644 --- a/crates/win-tts/Cargo.toml +++ b/crates/win-tts/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "win-tts" -version = "2.2.2" +version = "3.0.0-beta.1" authors = ["Markus Ast "] edition = "2018" diff --git a/mod/Mods/services/DATIS/entry.lua b/mod/Mods/services/DATIS/entry.lua index 4c5062d..0d02993 100644 --- a/mod/Mods/services/DATIS/entry.lua +++ b/mod/Mods/services/DATIS/entry.lua @@ -5,7 +5,7 @@ declare_plugin("DATIS", { "datis.dll", }, - version = "2.2.2", + version = "3.0.0-beta.1", state = "installed", developerName = "github.com/rkusa", info = _("DATIS enables a DCS server with an SRS server running on the same machine (TCP=127.0.0.1) to get weather from the mission for stations and frequencies set in the mission editor, and then to report same in a standardized format over SRS using either the Amazon or Google text to speech engines."), diff --git a/mod/Scripts/Hooks/datis-hook.lua b/mod/Scripts/Hooks/datis-hook.lua index 9b2f83f..e6e7e40 100644 --- a/mod/Scripts/Hooks/datis-hook.lua +++ b/mod/Scripts/Hooks/datis-hook.lua @@ -84,12 +84,58 @@ function datis_handleRequest(method, params) position = position }) + local weather = _current_mission.mission.weather + return { result = { windSpeed = wind.v, windDir = wind.a, temp = temp, pressure = pressure, + fogThickness = weather.fog.thickness, + fogVisibility = weather.fog.visibility, + dustDensity = weather.dust_density, + } + } + + elseif method == "get_clouds" then + local clouds = _current_mission.mission.weather.clouds + + if clouds.preset ~= nil then + local presets = nil + local func, err = loadfile(lfs.currentdir() .. '/Config/Effects/clouds.lua') + if err then + return { + error = "Error loading clouds.lua: " .. err + } + end + + local env = { + type = _G.type, + next = _G.next, + setmetatable = _G.setmetatable, + getmetatable = _G.getmetatable, + _ = _, + } + setfenv(func, env) + func() + local preset = env.clouds and env.clouds.presets and env.clouds.presets[clouds.preset] + if preset ~= nil then + return { + result = { + new = { + base = clouds.base, + preset = preset, + }, + } + } + end + end + + -- fallback to old clouds + return { + result = { + old = clouds, } }