From 762323b70f778528d43fad32e3d9916b1c197a8c Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 4 Dec 2023 15:09:29 -0500 Subject: [PATCH 01/24] feat: plugins --- .gitignore | 3 +- client/src/App.tsx | 1 + client/src/assets/constants.ts | 8 +- client/src/assets/types.ts | 1 + client/src/components/drawer/Routing.tsx | 9 +- client/src/hooks/usePersist.ts | 2 +- client/src/hooks/useStatic.ts | 2 + docs/pages/setup/standard.mdx | 41 ++-- or-tools/install.sh | 2 +- or-tools/tsp/tsp.cc | 54 ++++- server/algorithms/src/bootstrap/mod.rs | 5 +- server/algorithms/src/bootstrap/radius.rs | 2 +- server/algorithms/src/bootstrap/s2.rs | 2 +- server/algorithms/src/routing/basic.rs | 140 ------------ server/algorithms/src/routing/mod.rs | 44 +++- .../algorithms/src/routing/plugin_manager.rs | 213 ++++++++++++++++++ .../algorithms/src/routing/plugins/.gitkeep | 0 server/algorithms/src/routing/sorting.rs | 134 +++++++++++ server/algorithms/src/routing/tsp.rs | 202 ----------------- server/algorithms/src/utils.rs | 43 +++- server/api/src/private/misc.rs | 6 + server/api/src/public/v1/calculate.rs | 13 +- server/api/src/utils/response.rs | 1 + server/model/src/api/args.rs | 30 +-- server/model/src/api/mod.rs | 1 + server/model/src/api/sort_by.rs | 50 ++++ 26 files changed, 568 insertions(+), 441 deletions(-) delete mode 100644 server/algorithms/src/routing/basic.rs create mode 100644 server/algorithms/src/routing/plugin_manager.rs create mode 100644 server/algorithms/src/routing/plugins/.gitkeep create mode 100644 server/algorithms/src/routing/sorting.rs delete mode 100644 server/algorithms/src/routing/tsp.rs create mode 100644 server/model/src/api/sort_by.rs diff --git a/.gitignore b/.gitignore index c4c67ecd..f466f873 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,8 @@ docker-compose.yml server/target vrp_tests server/debug_files/* -server/algorithms/src/routing/tsp +server/algorithms/src/routing/plugins/**/* +!server/algorithms/src/routing/plugins/.gitkeep # misc .idea/* diff --git a/client/src/App.tsx b/client/src/App.tsx index d0594035..f9e2fa42 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -82,6 +82,7 @@ export default function App() { } setStatic('scannerType', res.scanner_type) setStatic('dangerous', res.dangerous || false) + setStatic('route_plugins', res.route_plugins || []) if (!res.logged_in) { router.navigate('/login') } diff --git a/client/src/assets/constants.ts b/client/src/assets/constants.ts index 117159d5..44a0dfca 100644 --- a/client/src/assets/constants.ts +++ b/client/src/assets/constants.ts @@ -203,9 +203,9 @@ export const CLUSTERING_MODES = [ export const SORT_BY = [ 'None', - 'GeoHash', - 'S2Cell', - 'TSP', - 'ClusterCount', 'Random', + 'S2Cell', + 'Geohash', + 'LatLon', + 'PointCount', ] as const diff --git a/client/src/assets/types.ts b/client/src/assets/types.ts index 9b03dbc6..bec0daa8 100644 --- a/client/src/assets/types.ts +++ b/client/src/assets/types.ts @@ -214,6 +214,7 @@ export interface Config { scanner_type: 'rdm' | 'unown' | 'hybrid' logged_in: boolean dangerous: boolean + route_plugins: string[] } export type CombinedState = Partial & Partial diff --git a/client/src/components/drawer/Routing.tsx b/client/src/components/drawer/Routing.tsx index 023643fd..84b39d14 100644 --- a/client/src/components/drawer/Routing.tsx +++ b/client/src/components/drawer/Routing.tsx @@ -40,6 +40,11 @@ export default function RoutingTab() { const isEditing = useStatic((s) => Object.values(s.layerEditing).some((v) => v), ) + const routePlugins = useStatic((s) => s.route_plugins) + + const sortByOptions = React.useMemo(() => { + return [...SORT_BY, ...routePlugins] + }, [routePlugins]) const fastest = cluster_mode === 'Fastest' return ( @@ -106,8 +111,8 @@ export default function RoutingTab() { Routing - - + + sort === sort_by)}> diff --git a/client/src/hooks/usePersist.ts b/client/src/hooks/usePersist.ts index 5e1feb88..64a9a84e 100644 --- a/client/src/hooks/usePersist.ts +++ b/client/src/hooks/usePersist.ts @@ -59,7 +59,7 @@ export interface UsePersist { tth: typeof TTH[number] lineColorRules: { distance: number; color: string }[] mode: typeof MODES[number] - sort_by: typeof SORT_BY[number] + sort_by: typeof SORT_BY[number] | string radius: number | '' min_points: number | '' route_split_level: number | '' diff --git a/client/src/hooks/useStatic.ts b/client/src/hooks/useStatic.ts index 5a87c977..f121d1e2 100644 --- a/client/src/hooks/useStatic.ts +++ b/client/src/hooks/useStatic.ts @@ -32,6 +32,7 @@ export interface UseStatic { totalStartTime: number totalLoadingTime: number selected: string[] + route_plugins: string[] tileServers: KojiTileServer[] kojiRoutes: { name: string; id: number; type: string }[] scannerRoutes: { name: string; id: number; type: string }[] @@ -110,6 +111,7 @@ export const useStatic = create((set, get) => ({ type: 'FeatureCollection', features: [], }, + route_plugins: [], layerEditing: { cutMode: false, dragMode: false, diff --git a/docs/pages/setup/standard.mdx b/docs/pages/setup/standard.mdx index c523d97b..c5c55d29 100644 --- a/docs/pages/setup/standard.mdx +++ b/docs/pages/setup/standard.mdx @@ -88,45 +88,32 @@ import { Callout } from 'nextra-theme-docs' 1. Compile the server: ```bash - cd ../server && cargo run -r # you might have to also install pkg-config (`apt install pkg-config`) + cd ../server && cargo install --path . --force ``` 1. Optionally install [PM2](https://pm2.keymetrics.io/) to run the server in the background: ```bash npm install pm2 -g - pm2 start "cargo run -r" --name koji # from the /server folder + pm2 start koji # from the /server folder ``` ## Updating -1. Pull update +```bash +# pull latest +git pull - ```bash - git pull - ``` +# recompile OR-Tools +./or-tools/install.sh -1. Recompile OR-Tools +# recompile client +cd client && yarn install && yarn build - ```bash - ./or-tools/install.sh - ``` +# recompile server +cd ../server && cargo install --path . --force -1. Recompile Client - - ```bash - cd client && yarn install && yarn build - ``` - -1. Recompile Server - - ```bash - cd ../server && cargo run -r - ``` - -1. If using pm2 - - ```bash - pm2 restart koji - ``` +# if using PM2 +pm2 restart koji +``` diff --git a/or-tools/install.sh b/or-tools/install.sh index 09389f98..905f1735 100755 --- a/or-tools/install.sh +++ b/or-tools/install.sh @@ -38,4 +38,4 @@ mkdir examples/koji cp ../tsp/tsp.cc ./examples/koji/koji.cc cp ../tsp/CMakeLists.txt ./examples/koji/CMakeLists.txt make build SOURCE=examples/koji/koji.cc -mv ./examples/koji/build/bin/koji ../../server/algorithms/src/routing/tsp +mv ./examples/koji/build/bin/koji ../../server/algorithms/src/routing/plugins/tsp diff --git a/or-tools/tsp/tsp.cc b/or-tools/tsp/tsp.cc index c2795324..82bd4be6 100644 --- a/or-tools/tsp/tsp.cc +++ b/or-tools/tsp/tsp.cc @@ -153,25 +153,55 @@ namespace operations_research } -int main() +std::vector split(const std::string &s, char delimiter) { - RawInput distance_matrix; - std::vector row; + std::vector tokens; + std::string token; + std::istringstream tokenStream(s); + while (std::getline(tokenStream, token, delimiter)) + { + tokens.push_back(token); + } + return tokens; +} - std::string line; - while (std::getline(std::cin, line, ',') && !line.empty()) +int main(int argc, char *argv[]) +{ + std::map args; + RawInput points; + + for (int i = 1; i < argc; ++i) { - if (line == " ") + std::string arg = argv[i]; + if (arg.find("--") == 0) { - distance_matrix.push_back(row); - row.clear(); - continue; + std::string key = arg.substr(2); + if (key == "input") + { + std::string pointsStr = argv[++i]; + std::vector pointStrings = split(pointsStr, ' '); + for (const auto &pointStr : pointStrings) + { + auto coordinates = split(pointStr, ','); + if (coordinates.size() == 2) + { + double lat = std::stod(coordinates[0]); + double lng = std::stod(coordinates[1]); + points.push_back({lat, lng}); + } + } + } + else + { + if (i + 1 < argc) + { + args[key] = argv[++i]; + } + } } - double value = std::stod(line); - row.push_back(value); } - RawInput routes = operations_research::Tsp(distance_matrix); + RawInput routes = operations_research::Tsp(points); for (auto route : routes) { for (auto node : route) diff --git a/server/algorithms/src/bootstrap/mod.rs b/server/algorithms/src/bootstrap/mod.rs index 35050509..90b33ce0 100644 --- a/server/algorithms/src/bootstrap/mod.rs +++ b/server/algorithms/src/bootstrap/mod.rs @@ -1,8 +1,5 @@ use geojson::{Feature, FeatureCollection}; -use model::api::{ - args::{CalculationMode, SortBy}, - Precision, -}; +use model::api::{args::CalculationMode, sort_by::SortBy, Precision}; use crate::stats::Stats; diff --git a/server/algorithms/src/bootstrap/radius.rs b/server/algorithms/src/bootstrap/radius.rs index d7e59b93..227cf5ce 100644 --- a/server/algorithms/src/bootstrap/radius.rs +++ b/server/algorithms/src/bootstrap/radius.rs @@ -5,7 +5,7 @@ use crate::{routing, stats::Stats}; use geo::{Contains, Extremes, HaversineDestination, HaversineDistance, Point, Polygon}; use geojson::{Feature, Geometry, Value}; use model::{ - api::{args::SortBy, single_vec::SingleVec, Precision, ToFeature, ToGeometryVec}, + api::{single_vec::SingleVec, sort_by::SortBy, Precision, ToFeature, ToGeometryVec}, db::sea_orm_active_enums::Type, }; use rayon::prelude::{IntoParallelRefIterator, ParallelIterator}; diff --git a/server/algorithms/src/bootstrap/s2.rs b/server/algorithms/src/bootstrap/s2.rs index 1e9150c1..2c68175f 100644 --- a/server/algorithms/src/bootstrap/s2.rs +++ b/server/algorithms/src/bootstrap/s2.rs @@ -10,7 +10,7 @@ use geo::{ConvexHull, Intersects, MultiPolygon, Polygon}; use geojson::{Feature, Value}; use hashbrown::HashSet; use model::{ - api::{args::SortBy, single_vec::SingleVec, Precision, ToFeature}, + api::{single_vec::SingleVec, sort_by::SortBy, Precision, ToFeature}, db::sea_orm_active_enums::Type, }; use rayon::{ diff --git a/server/algorithms/src/routing/basic.rs b/server/algorithms/src/routing/basic.rs deleted file mode 100644 index 4d5bf469..00000000 --- a/server/algorithms/src/routing/basic.rs +++ /dev/null @@ -1,140 +0,0 @@ -use geo::Coord; -use geohash::{decode, encode}; -use model::api::{args::SortBy, single_vec::SingleVec}; -use rand::rngs::mock::StepRng; -use rayon::{ - iter::{IntoParallelIterator, ParallelIterator}, - slice::ParallelSliceMut, -}; -use s2::{cell::Cell, latlng::LatLng}; -use shuffle::{irs::Irs, shuffler::Shuffler}; - -use crate::rtree::{self, cluster::Cluster, point}; - -pub trait ClusterSorting { - fn sort_random(self) -> Self; - fn sort_random_mut(&mut self); - fn sort_geohash(&self) -> Self; - fn sort_geohash_mut(&mut self); - fn sort_s2(&self) -> Self; - fn sort_s2_mut(&mut self); - fn sort_point_count(&self, points: &SingleVec, radius: f64) -> Self; - fn sort_point_count_mut(&mut self, points: &SingleVec, radius: f64); - fn sort_lat_lng(&self) -> Self; - fn sort_lat_lng_mut(&mut self); -} - -impl ClusterSorting for SingleVec { - fn sort_point_count(&self, points: &SingleVec, radius: f64) -> Self { - let tree = rtree::spawn(radius, points); - let clusters: Vec = self - .into_par_iter() - .map(|c| point::Point::new(radius, 20, *c)) - .collect(); - - let mut clusters: Vec> = rtree::cluster_info(&tree, &clusters); - - clusters.par_sort_by(|a, b| b.all.len().cmp(&a.all.len())); - - clusters.into_iter().map(|c| c.point.center).collect() - } - - fn sort_point_count_mut(&mut self, points: &SingleVec, radius: f64) { - *self = self.sort_point_count(points, radius) - } - - fn sort_random(self) -> Self { - let mut clusters = self; - clusters.sort_random_mut(); - clusters - } - - fn sort_random_mut(&mut self) { - let mut rng = StepRng::new(2, 13); - let mut irs = Irs::default(); - match irs.shuffle(self, &mut rng) { - Ok(_) => {} - Err(e) => { - log::warn!("Error while shuffling: {}", e); - } - } - } - - fn sort_geohash(&self) -> Self { - let mut points: Vec = self - .into_iter() - .filter_map(|p| match encode(Coord { x: p[1], y: p[0] }, 12) { - Ok(geohash) => Some(geohash), - Err(e) => { - log::warn!("Error while encoding geohash: {}", e); - None - } - }) - .collect(); - - points.par_sort(); - - points - .into_iter() - .map(|p| { - let coord = decode(&p); - match coord { - Ok(coord) => [coord.0.y, coord.0.x], - Err(e) => { - log::warn!("Error while decoding geohash: {}", e); - [0., 0.] - } - } - }) - .collect() - } - - fn sort_geohash_mut(&mut self) { - *self = self.sort_geohash() - } - - fn sort_s2(&self) -> Self { - let mut points: Vec = self - .into_iter() - .map(|p| LatLng::from_degrees(p[0], p[1]).into()) - .collect(); - - points.par_sort_by(|a, b| a.id.cmp(&b.id)); - - points - .into_iter() - .map(|p| { - let center = p.center(); - [center.latitude().deg(), center.longitude().deg()] - }) - .collect() - } - - fn sort_s2_mut(&mut self) { - *self = self.sort_s2() - } - - fn sort_lat_lng(&self) -> Self { - let mut points = self.clone(); - points.sort_lat_lng_mut(); - points - } - - fn sort_lat_lng_mut(&mut self) { - self.sort_by(|a, b| { - b[0].partial_cmp(&a[0]) - .unwrap_or(std::cmp::Ordering::Equal) - .then(b[1].partial_cmp(&a[1]).unwrap_or(std::cmp::Ordering::Equal)) - }) - } -} - -pub fn sort(points: &SingleVec, clusters: SingleVec, radius: f64, sort_by: &SortBy) -> SingleVec { - match sort_by { - SortBy::Random => clusters.sort_random(), - SortBy::GeoHash => clusters.sort_geohash(), - SortBy::S2Cell => clusters.sort_s2(), - SortBy::ClusterCount => clusters.sort_point_count(points, radius), - _ => clusters, - } -} diff --git a/server/algorithms/src/routing/mod.rs b/server/algorithms/src/routing/mod.rs index 919752ed..610aed10 100644 --- a/server/algorithms/src/routing/mod.rs +++ b/server/algorithms/src/routing/mod.rs @@ -1,11 +1,19 @@ use std::time::Instant; -use model::api::{args::SortBy, single_vec::SingleVec}; +use model::api::{single_vec::SingleVec, sort_by::SortBy}; -use crate::{stats::Stats, utils::rotate_to_best}; +use crate::{ + stats::Stats, + utils::{get_plugin_list, rotate_to_best}, +}; -pub mod basic; -pub mod tsp; +use self::{ + plugin_manager::PluginManager, + sorting::{SortGeohash, SortLatLng, SortPointCount, SortRandom, SortS2}, +}; + +pub mod plugin_manager; +pub mod sorting; // pub mod vrp; pub fn main( @@ -17,10 +25,26 @@ pub fn main( stats: &mut Stats, ) -> SingleVec { let route_time = Instant::now(); - let clusters = if sort_by == &SortBy::TSP && !clusters.is_empty() { - tsp::run(clusters, route_split_level) - } else { - basic::sort(&data_points, clusters, radius, sort_by) + let clusters = match sort_by { + SortBy::PointCount => clusters.sort_point_count(&data_points, radius), + SortBy::LatLon => clusters.sort_lat_lng(), + SortBy::GeoHash => clusters.sort_geohash(), + SortBy::S2Cell => clusters.sort_s2(), + SortBy::Random => clusters.sort_random(), + SortBy::Unset => clusters, + SortBy::Custom(plugin) => { + if let Ok(plugin_manager) = + PluginManager::new(plugin, route_split_level, radius, &clusters) + { + if let Ok(sorted_clusters) = plugin_manager.run() { + sorted_clusters + } else { + clusters + } + } else { + clusters + } + } }; let clusters = rotate_to_best(clusters, stats); @@ -29,3 +53,7 @@ pub fn main( clusters } + +pub fn routing_plugins() -> Vec { + get_plugin_list("algorithms/src/routing/plugins").unwrap_or(vec![]) +} diff --git a/server/algorithms/src/routing/plugin_manager.rs b/server/algorithms/src/routing/plugin_manager.rs new file mode 100644 index 00000000..e4ec3e94 --- /dev/null +++ b/server/algorithms/src/routing/plugin_manager.rs @@ -0,0 +1,213 @@ +use std::collections::HashMap; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::Instant; + +use geo::{HaversineDistance, Point}; +use s2::cellid::CellID; +use s2::latlng::LatLng; + +use crate::routing::sorting::SortS2; +use crate::s2::create_cell_map; +use crate::utils::{self, stringify_points}; +use model::api::{point_array::PointArray, single_vec::SingleVec}; + +pub struct PluginManager<'a> { + plugin: String, + plugin_path: String, + route_split_level: u64, + radius: f64, + clusters: &'a SingleVec, +} + +impl<'a> PluginManager<'a> { + pub fn new( + plugin: &str, + route_split_level: u64, + radius: f64, + clusters: &'a SingleVec, + ) -> std::io::Result { + let path = Path::new("algorithms/src/routing/plugins"); + let path = path.join(plugin); + let plugin_path = if path.exists() { + path.display().to_string() + } else { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!( + "{plugin} does not exist{}", + if plugin == "tsp" { + ", rerun the OR Tools Script" + } else { + "" + } + ), + )); + }; + + Ok(PluginManager { + plugin: plugin.to_string(), + plugin_path, + route_split_level, + radius, + clusters, + }) + } + + // pub fn run(self) -> SingleVec { + // match self.error_wrapper() { + // Ok(result) => result, + // Err(err) => { + // log::error!("{} failed: {}", self.plugin, err); + // self.clusters + // } + // } + // } + + pub fn run(self) -> Result { + log::info!("starting {}...", self.plugin); + let time = Instant::now(); + + if self.route_split_level < 2 || self.plugin != "tsp" { + return self.spawn_child_process(self.clusters); + } + let get_cell_id = |point: PointArray| { + CellID::from(LatLng::from_degrees(point[0], point[1])) + .parent(self.route_split_level) + .0 + }; + let merged_routes: Vec<(PointArray, SingleVec)> = + create_cell_map(&self.clusters, self.route_split_level as u64) + .into_iter() + .enumerate() + .map(|(i, (cell_id, segment))| { + log::debug!("Creating thread: {} for hash {}", i + 1, cell_id); + let mut route = self.spawn_child_process(&segment).unwrap_or(vec![]); + if let Some(last) = route.last() { + if let Some(first) = route.first() { + if first == last { + route.pop(); + } + } + } + ( + if route.len() > 0 { + utils::centroid(&route) + } else { + [0., 0.] + }, + route, + ) + }) + .collect(); + let mut centroids = vec![]; + + let mut point_map = HashMap::::new(); + merged_routes + .into_iter() + .enumerate() + .for_each(|(_i, (hash, r))| { + centroids.push(hash); + point_map.insert(get_cell_id(hash), r); + }); + + let clusters: Vec = self + .spawn_child_process(¢roids)? + .into_iter() + .filter_map(|c| { + let hash = get_cell_id(c); + point_map.remove(&hash) + }) + .collect(); + + let mut final_routes: SingleVec = vec![]; + + for (i, current) in clusters.clone().iter_mut().enumerate() { + let next: &SingleVec = if i == clusters.len() - 1 { + clusters[0].as_ref() + } else { + clusters[i + 1].as_ref() + }; + + let mut shortest = std::f64::MAX; + let mut shortest_current_index = 0; + + for (current_index, current_point) in current.iter().enumerate() { + let current_point = Point::new(current_point[1], current_point[0]); + for (_next_index, next_point) in next.iter().enumerate() { + let next_point = Point::new(next_point[1], next_point[0]); + let distance = current_point.haversine_distance(&next_point); + if distance < shortest { + shortest = distance; + shortest_current_index = current_index; + } + } + } + current.rotate_left(shortest_current_index); + final_routes.append(current); + } + + log::info!("full tsp time: {}", time.elapsed().as_secs_f32()); + Ok(final_routes) + } + + fn spawn_child_process(&self, points: &SingleVec) -> Result { + log::info!("spawning {} child process", self.plugin); + let time = Instant::now(); + let clusters = points.clone().sort_s2(); + let stringified_points = stringify_points(&clusters); + let mut child = match Command::new(&self.plugin_path) + .args(&["--input", &stringified_points]) + .args(&["--radius", &self.radius.to_string()]) + .args(&["--route_split_level", &self.route_split_level.to_string()]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(err) => return Err(err), + }; + + let mut stdin = match child.stdin.take() { + Some(stdin) => stdin, + None => { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Failed to open stdin", + )); + } + }; + + std::thread::spawn( + move || match stdin.write_all(stringified_points.as_bytes()) { + Ok(_) => match stdin.flush() { + Ok(_) => {} + Err(err) => { + log::error!("failed to flush stdin: {}", err); + } + }, + Err(err) => { + log::error!("failed to write to stdin: {}", err) + } + }, + ); + + let output = match child.wait_with_output() { + Ok(result) => result, + Err(err) => return Err(err), + }; + let output = String::from_utf8_lossy(&output.stdout); + let output = output + .split(",") + .filter_map(|s| s.parse::().ok()) + .collect::>(); + + log::info!( + "{} child process finished in {}s", + self.plugin, + time.elapsed().as_secs_f32() + ); + Ok(output.into_iter().map(|i| clusters[i]).collect()) + } +} diff --git a/server/algorithms/src/routing/plugins/.gitkeep b/server/algorithms/src/routing/plugins/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/server/algorithms/src/routing/sorting.rs b/server/algorithms/src/routing/sorting.rs new file mode 100644 index 00000000..82cf710c --- /dev/null +++ b/server/algorithms/src/routing/sorting.rs @@ -0,0 +1,134 @@ +use geo::Coord; +use geohash::encode; +use model::api::single_vec::SingleVec; +use rand::rngs::mock::StepRng; +use rayon::{ + iter::{IntoParallelRefIterator, ParallelIterator}, + slice::ParallelSliceMut, +}; +use s2::{cellid::CellID, latlng::LatLng}; +use shuffle::{irs::Irs, shuffler::Shuffler}; + +use crate::rtree::{self, cluster, point}; + +pub trait SortRandom { + fn sort_random(self) -> Self; + fn sort_random_mut(&mut self); +} + +impl SortRandom for SingleVec { + fn sort_random(self) -> Self { + let mut clusters = self; + clusters.sort_random_mut(); + clusters + } + + fn sort_random_mut(&mut self) { + let mut rng = StepRng::new(2, 13); + let mut irs = Irs::default(); + match irs.shuffle(self, &mut rng) { + Ok(_) => {} + Err(e) => { + log::warn!("Error while shuffling: {}", e); + } + } + } +} + +pub trait SortGeohash { + fn sort_geohash(self) -> Self; + fn sort_geohash_mut(&mut self); +} + +impl SortGeohash for SingleVec { + fn sort_geohash(self) -> Self { + let mut points = self; + points.sort_geohash_mut(); + points + } + + fn sort_geohash_mut(&mut self) { + self.par_sort_by(|a, b| { + match encode(Coord { x: a[1], y: a[0] }, 12) { + Ok(geohash) => geohash, + Err(e) => { + log::warn!("Error while encoding geohash: {}", e); + "".to_string() + } + } + .cmp(&match encode(Coord { x: b[1], y: b[0] }, 12) { + Ok(geohash) => geohash, + Err(e) => { + log::warn!("Error while encoding geohash: {}", e); + "".to_string() + } + }) + }) + } +} + +pub trait SortS2 { + fn sort_s2(self) -> Self; + fn sort_s2_mut(&mut self); +} + +impl SortS2 for SingleVec { + fn sort_s2(self) -> Self { + let mut points = self; + points.sort_s2_mut(); + points + } + + fn sort_s2_mut(&mut self) { + self.par_sort_by(|a, b| { + let a: CellID = LatLng::from_degrees(a[0], a[1]).into(); + let b: CellID = LatLng::from_degrees(b[0], b[1]).into(); + a.0.cmp(&b.0) + }); + } +} + +pub trait SortLatLng { + fn sort_lat_lng(self) -> Self; + fn sort_lat_lng_mut(&mut self); +} + +impl SortLatLng for SingleVec { + fn sort_lat_lng(self) -> Self { + let mut points = self; + points.sort_lat_lng_mut(); + points + } + + fn sort_lat_lng_mut(&mut self) { + self.par_sort_by(|a, b| { + b[0].partial_cmp(&a[0]) + .unwrap_or(std::cmp::Ordering::Equal) + .then(b[1].partial_cmp(&a[1]).unwrap_or(std::cmp::Ordering::Equal)) + }) + } +} + +pub trait SortPointCount { + fn sort_point_count(self, points: &SingleVec, radius: f64) -> Self; + fn sort_point_count_mut(&mut self, points: &SingleVec, radius: f64); +} + +impl SortPointCount for SingleVec { + fn sort_point_count(self, points: &SingleVec, radius: f64) -> Self { + let mut clusters = self; + clusters.sort_point_count_mut(points, radius); + clusters + } + + fn sort_point_count_mut(&mut self, points: &SingleVec, radius: f64) { + let tree = rtree::spawn(radius, points); + let clusters: Vec = self + .par_iter() + .map(|c| point::Point::new(radius, 20, *c)) + .collect(); + let mut clusters: Vec> = rtree::cluster_info(&tree, &clusters); + clusters.par_sort_by(|a, b| b.all.len().cmp(&a.all.len())); + *self = clusters.into_iter().map(|c| c.point.center).collect(); + } +} diff --git a/server/algorithms/src/routing/tsp.rs b/server/algorithms/src/routing/tsp.rs deleted file mode 100644 index b624585b..00000000 --- a/server/algorithms/src/routing/tsp.rs +++ /dev/null @@ -1,202 +0,0 @@ -use std::collections::HashMap; -use std::io::Write; -use std::process::{Command, Stdio}; -use std::time::Instant; -use std::vec; - -use geo::{HaversineDistance, Point}; -use s2::cellid::CellID; -use s2::latlng::LatLng; - -use crate::s2::create_cell_map; -use crate::utils; -use model::api::{point_array::PointArray, single_vec::SingleVec}; - -use super::basic::ClusterSorting; - -pub fn run(clusters: SingleVec, route_split_level: u64) -> SingleVec { - log::info!("starting TSP..."); - let time = Instant::now(); - - if route_split_level < 2 { - return or_tools(clusters); - } - let get_cell_id = |point: PointArray| { - CellID::from(LatLng::from_degrees(point[0], point[1])) - .parent(route_split_level) - .0 - }; - let merged_routes: Vec<(PointArray, SingleVec)> = - create_cell_map(&clusters, route_split_level as u64) - .into_iter() - .enumerate() - .map(|(i, (cell_id, segment))| { - log::debug!("Creating thread: {} for hash {}", i + 1, cell_id); - let mut route = or_tools(segment); - if let Some(last) = route.last() { - if let Some(first) = route.first() { - if first == last { - route.pop(); - } - } - } - ( - if route.len() > 0 { - utils::centroid(&route) - } else { - [0., 0.] - }, - route, - ) - }) - .collect(); - let mut centroids = vec![]; - - let mut point_map = HashMap::::new(); - merged_routes - .into_iter() - .enumerate() - .for_each(|(_i, (hash, r))| { - centroids.push(hash); - point_map.insert(get_cell_id(hash), r); - }); - - let clusters: Vec = or_tools(centroids) - .into_iter() - .filter_map(|c| { - let hash = get_cell_id(c); - point_map.remove(&hash) - }) - .collect(); - - let mut final_routes: SingleVec = vec![]; - - for (i, current) in clusters.clone().iter_mut().enumerate() { - let next: &SingleVec = if i == clusters.len() - 1 { - clusters[0].as_ref() - } else { - clusters[i + 1].as_ref() - }; - - let mut shortest = std::f64::MAX; - let mut shortest_current_index = 0; - - for (current_index, current_point) in current.iter().enumerate() { - let current_point = Point::new(current_point[1], current_point[0]); - for (_next_index, next_point) in next.iter().enumerate() { - let next_point = Point::new(next_point[1], next_point[0]); - let distance = current_point.haversine_distance(&next_point); - if distance < shortest { - shortest = distance; - shortest_current_index = current_index; - } - } - } - current.rotate_left(shortest_current_index); - final_routes.append(current); - } - - log::info!("full tsp time: {}", time.elapsed().as_secs_f32()); - final_routes -} - -fn directory() -> std::io::Result { - let mut path = std::env::current_dir()?; - path.push("algorithms"); - path.push("src"); - path.push("routing"); - path.push("tsp"); - if path.exists() { - Ok(path.display().to_string()) - } else { - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "TSP solver does not exist, rerun the OR Tools Script", - )) - } -} - -fn stringify_points(points: &SingleVec) -> String { - points - .iter() - .enumerate() - .map(|(i, cluster)| { - format!( - "{},{}, {}", - cluster[0], - cluster[1], - if i == points.len() - 1 { "" } else { "," } - ) - }) - .collect() -} - -fn spawn_tsp(dir: String, clusters: &SingleVec) -> Result { - log::info!("spawning TSP child process"); - let time = Instant::now(); - let clusters = clusters.sort_s2(); - let stringified_points = stringify_points(&clusters); - let mut child = match Command::new(&dir) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - { - Ok(child) => child, - Err(err) => return Err(err), - }; - - let mut stdin = match child.stdin.take() { - Some(stdin) => stdin, - None => { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "Failed to open stdin", - )); - } - }; - - std::thread::spawn( - move || match stdin.write_all(stringified_points.as_bytes()) { - Ok(_) => match stdin.flush() { - Ok(_) => {} - Err(err) => { - log::error!("failed to flush stdin: {}", err); - } - }, - Err(err) => { - log::error!("failed to write to stdin: {}", err) - } - }, - ); - - let output = match child.wait_with_output() { - Ok(result) => result, - Err(err) => return Err(err), - }; - let output = String::from_utf8_lossy(&output.stdout); - let output = output - .split(",") - .filter_map(|s| s.parse::().ok()) - .collect::>(); - - log::info!( - "TSP child process finished in {}s", - time.elapsed().as_secs_f32() - ); - Ok(output.into_iter().map(|i| clusters[i]).collect()) -} - -fn or_tools(clusters: SingleVec) -> SingleVec { - if let Ok(dir) = directory() { - match spawn_tsp(dir, &clusters) { - Ok(result) => result, - Err(err) => { - log::error!("TSP failed to spawn child process {}", err); - clusters - } - } - } else { - log::error!("TSP solver not found, rerun the OR-Tools script to generate it"); - clusters - } -} diff --git a/server/algorithms/src/utils.rs b/server/algorithms/src/utils.rs index 876b3d4e..191e708e 100644 --- a/server/algorithms/src/utils.rs +++ b/server/algorithms/src/utils.rs @@ -1,7 +1,9 @@ use std::collections::{HashMap, VecDeque}; use std::fmt::Debug; +use std::fs; use std::fs::{create_dir_all, File}; -use std::io::{Result, Write}; +use std::io::Write; +use std::path::Path; use colored::Colorize; use geo::Coord; @@ -12,7 +14,7 @@ use model::api::{point_array::PointArray, single_vec::SingleVec}; use crate::rtree::cluster::Cluster; use crate::stats::Stats; -pub fn debug_hashmap(file_name: &str, input: &T) -> Result<()> +pub fn debug_hashmap(file_name: &str, input: &T) -> std::io::Result<()> where U: Debug, T: Debug + Clone + IntoIterator, @@ -31,7 +33,7 @@ where Ok(()) } -pub fn debug_string(file_name: &str, input: &String) -> Result<()> { +pub fn debug_string(file_name: &str, input: &String) -> std::io::Result<()> { create_dir_all("./debug_files")?; let path = format!("./debug_files/{}", file_name); let mut output = File::create(path)?; @@ -140,3 +142,38 @@ pub fn rotate_to_best(clusters: SingleVec, stats: &Stats) -> SingleVec { final_clusters.into() } + +pub fn get_plugin_list(path: &str) -> std::io::Result> { + let path = Path::new(path); + + fs::read_dir(path)? + .map(|res| res.map(|e| e.path().display().to_string())) + .filter_map(|path| { + if let Ok(ext) = path { + let plugin = ext.split("/").last().unwrap_or("").to_string(); + if plugin == ".gitkeep" { + None + } else { + Some(Ok(plugin)) + } + } else { + None + } + }) + .collect::, std::io::Error>>() +} + +pub fn stringify_points(points: &SingleVec) -> String { + points + .iter() + .enumerate() + .map(|(i, cluster)| { + format!( + "{},{}{}", + cluster[0], + cluster[1], + if i == points.len() - 1 { "" } else { " " } + ) + }) + .collect() +} diff --git a/server/api/src/private/misc.rs b/server/api/src/private/misc.rs index 75ef7845..ed32ae8d 100644 --- a/server/api/src/private/misc.rs +++ b/server/api/src/private/misc.rs @@ -8,6 +8,7 @@ use crate::{ use actix_session::Session; use actix_web::http::header; +use algorithms::routing; use geojson::Value; use model::{api::args::Auth, KojiDb}; use serde_json::json; @@ -23,7 +24,11 @@ async fn config(conn: web::Data, session: Session) -> Result, session: Session) -> Result) -> Result { let mut stats = Stats::new(String::from("Reroute"), 1); // For legacy compatibility - let clusters = if clusters.is_empty() { - data_points.clone() + let (clusters, data_points) = if clusters.is_empty() { + (data_points, vec![]) } else { - clusters + (clusters, data_points) }; stats.total_clusters = clusters.len(); diff --git a/server/api/src/utils/response.rs b/server/api/src/utils/response.rs index 52847574..205e392e 100644 --- a/server/api/src/utils/response.rs +++ b/server/api/src/utils/response.rs @@ -20,6 +20,7 @@ pub struct ConfigResponse { pub scanner_type: ScannerType, pub logged_in: bool, pub dangerous: bool, + pub route_plugins: Vec, } #[derive(Debug, Serialize, Clone)] diff --git a/server/model/src/api/args.rs b/server/model/src/api/args.rs index 77d5416e..5c82e425 100644 --- a/server/model/src/api/args.rs +++ b/server/model/src/api/args.rs @@ -1,4 +1,4 @@ -use super::{cluster_mode::ClusterMode, *}; +use super::{cluster_mode::ClusterMode, sort_by::SortBy, *}; use crate::{ api::{collection::Default, text::TextHelpers}, @@ -157,32 +157,6 @@ pub enum ReturnTypeArg { Poracle, } -#[derive(Debug, Deserialize, Clone)] -pub enum SortBy { - None, - GeoHash, - ClusterCount, - Random, - S2Cell, - TSP, -} - -impl PartialEq for SortBy { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (SortBy::None, SortBy::None) => true, - (SortBy::GeoHash, SortBy::GeoHash) => true, - (SortBy::ClusterCount, SortBy::ClusterCount) => true, - (SortBy::Random, SortBy::Random) => true, - (SortBy::S2Cell, SortBy::S2Cell) => true, - (SortBy::TSP, SortBy::TSP) => true, - _ => false, - } - } -} - -impl Eq for SortBy {} - #[derive(Debug, Serialize, Deserialize, Clone)] pub enum SpawnpointTth { All, @@ -540,7 +514,7 @@ impl Args { let save_to_db = save_to_db.unwrap_or(false); let save_to_scanner = save_to_scanner.unwrap_or(false); let simplify = simplify.unwrap_or(false); - let sort_by = sort_by.unwrap_or(SortBy::None); + let sort_by = sort_by.unwrap_or(SortBy::Unset); let tth = tth.unwrap_or(SpawnpointTth::All); let mode = get_enum(mode); let route_split_level = validate_s2_cell(route_split_level, "route_split_level"); diff --git a/server/model/src/api/mod.rs b/server/model/src/api/mod.rs index 39c074b7..46723aca 100644 --- a/server/model/src/api/mod.rs +++ b/server/model/src/api/mod.rs @@ -19,6 +19,7 @@ pub mod point_struct; pub mod poracle; pub mod single_struct; pub mod single_vec; +pub mod sort_by; pub mod text; pub type Precision = f64; diff --git a/server/model/src/api/sort_by.rs b/server/model/src/api/sort_by.rs new file mode 100644 index 00000000..4a4fba87 --- /dev/null +++ b/server/model/src/api/sort_by.rs @@ -0,0 +1,50 @@ +use serde::Deserialize; + +#[derive(Debug, Clone)] +pub enum SortBy { + Unset, + GeoHash, + PointCount, + Random, + S2Cell, + LatLon, + Custom(String), +} + +impl PartialEq for SortBy { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (SortBy::Unset, SortBy::Unset) => true, + (SortBy::GeoHash, SortBy::GeoHash) => true, + (SortBy::PointCount, SortBy::PointCount) => true, + (SortBy::Random, SortBy::Random) => true, + (SortBy::S2Cell, SortBy::S2Cell) => true, + _ => false, + } + } +} + +impl Eq for SortBy {} + +impl<'de> Deserialize<'de> for SortBy { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s: String = Deserialize::deserialize(deserializer)?; + + match s.to_lowercase().as_str() { + "geohash" => Ok(SortBy::GeoHash), + "cluster_count" | "point_count" | "clustercount" | "pointcount" => { + Ok(SortBy::PointCount) + } + "random" => Ok(SortBy::Random), + "s2" | "s2cell" => Ok(SortBy::S2Cell), + "latlon" => Ok(SortBy::LatLon), + "" | "none" | "unset" => Ok(SortBy::Unset), + // This is for backwards compatibility since the custom below would end up with a value of "TSP" + "tsp" => Ok(SortBy::Custom("tsp".to_string())), + _ => Ok(SortBy::Custom(s)), + } + } +} From 75bece89064ff935fc33dd4333b62256ad2aeda3 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 4 Dec 2023 16:02:33 -0500 Subject: [PATCH 02/24] fix: add python and node interpreter options --- client/src/components/drawer/Routing.tsx | 14 +++- server/algorithms/src/routing/mod.rs | 18 +++-- .../algorithms/src/routing/plugin_manager.rs | 73 +++++++++++++------ 3 files changed, 75 insertions(+), 30 deletions(-) diff --git a/client/src/components/drawer/Routing.tsx b/client/src/components/drawer/Routing.tsx index 84b39d14..cda91bcd 100644 --- a/client/src/components/drawer/Routing.tsx +++ b/client/src/components/drawer/Routing.tsx @@ -111,7 +111,19 @@ export default function RoutingTab() { Routing - + { + if (item === 'tsp') return 'TSP' + if (item.includes('.')) { + const [plugin, ext] = item.split('.') + return `${plugin} (${ext})` + } + return item + }} + /> sort === sort_by)}> diff --git a/server/algorithms/src/routing/mod.rs b/server/algorithms/src/routing/mod.rs index 610aed10..d2c74834 100644 --- a/server/algorithms/src/routing/mod.rs +++ b/server/algorithms/src/routing/mod.rs @@ -33,16 +33,18 @@ pub fn main( SortBy::Random => clusters.sort_random(), SortBy::Unset => clusters, SortBy::Custom(plugin) => { - if let Ok(plugin_manager) = - PluginManager::new(plugin, route_split_level, radius, &clusters) - { - if let Ok(sorted_clusters) = plugin_manager.run() { - sorted_clusters - } else { + match PluginManager::new(plugin, route_split_level, radius, &clusters) { + Ok(plugin_manager) => match plugin_manager.run() { + Ok(sorted_clusters) => sorted_clusters, + Err(e) => { + log::error!("Error while running plugin: {}", e); + clusters + } + }, + Err(e) => { + log::error!("Plugin not found: {}", e); clusters } - } else { - clusters } } }; diff --git a/server/algorithms/src/routing/plugin_manager.rs b/server/algorithms/src/routing/plugin_manager.rs index e4ec3e94..26186ba7 100644 --- a/server/algorithms/src/routing/plugin_manager.rs +++ b/server/algorithms/src/routing/plugin_manager.rs @@ -13,9 +13,11 @@ use crate::s2::create_cell_map; use crate::utils::{self, stringify_points}; use model::api::{point_array::PointArray, single_vec::SingleVec}; +#[derive(Debug)] pub struct PluginManager<'a> { plugin: String, plugin_path: String, + interpreter: String, route_split_level: u64, radius: f64, clusters: &'a SingleVec, @@ -46,25 +48,31 @@ impl<'a> PluginManager<'a> { )); }; + let interpreter = match plugin.split(".").last() { + Some("py") => "python3", + Some("js") => "node", + Some("ts") => "ts-node", + val => { + if plugin == val.unwrap_or("") { + "" + } else { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Unrecognized plugin, please create a PR to add support for it", + )); + } + } + }; Ok(PluginManager { plugin: plugin.to_string(), plugin_path, + interpreter: interpreter.to_string(), route_split_level, radius, clusters, }) } - // pub fn run(self) -> SingleVec { - // match self.error_wrapper() { - // Ok(result) => result, - // Err(err) => { - // log::error!("{} failed: {}", self.plugin, err); - // self.clusters - // } - // } - // } - pub fn run(self) -> Result { log::info!("starting {}...", self.plugin); let time = Instant::now(); @@ -148,7 +156,11 @@ impl<'a> PluginManager<'a> { final_routes.append(current); } - log::info!("full tsp time: {}", time.elapsed().as_secs_f32()); + log::info!( + "full {} time: {}", + self.plugin, + time.elapsed().as_secs_f32() + ); Ok(final_routes) } @@ -157,7 +169,16 @@ impl<'a> PluginManager<'a> { let time = Instant::now(); let clusters = points.clone().sort_s2(); let stringified_points = stringify_points(&clusters); - let mut child = match Command::new(&self.plugin_path) + + let mut child = if self.interpreter.is_empty() { + Command::new(&self.plugin_path) + } else { + Command::new(&self.interpreter) + }; + if !self.interpreter.is_empty() { + child.arg(&self.plugin_path); + } + let mut child = match child .args(&["--input", &stringified_points]) .args(&["--radius", &self.radius.to_string()]) .args(&["--route_split_level", &self.route_split_level.to_string()]) @@ -174,7 +195,7 @@ impl<'a> PluginManager<'a> { None => { return Err(std::io::Error::new( std::io::ErrorKind::NotFound, - "Failed to open stdin", + "failed to open stdin", )); } }; @@ -198,16 +219,26 @@ impl<'a> PluginManager<'a> { Err(err) => return Err(err), }; let output = String::from_utf8_lossy(&output.stdout); - let output = output + let output_indexes = output .split(",") - .filter_map(|s| s.parse::().ok()) + .filter_map(|s| s.trim().parse::().ok()) .collect::>(); - log::info!( - "{} child process finished in {}s", - self.plugin, - time.elapsed().as_secs_f32() - ); - Ok(output.into_iter().map(|i| clusters[i]).collect()) + if output_indexes.is_empty() { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!( + "no valid output from child process {}, output should return comma separated indexes of the input clusters in the order they should be routed", + output + ), + )) + } else { + log::info!( + "{} child process finished in {}s", + self.plugin, + time.elapsed().as_secs_f32() + ); + Ok(output_indexes.into_iter().map(|i| clusters[i]).collect()) + } } } From 25e0e56de77529ac2a70821be09502bdc26e4fd9 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 4 Dec 2023 16:56:03 -0500 Subject: [PATCH 03/24] Update misc.rs --- server/api/src/private/misc.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/server/api/src/private/misc.rs b/server/api/src/private/misc.rs index ed32ae8d..2adba202 100644 --- a/server/api/src/private/misc.rs +++ b/server/api/src/private/misc.rs @@ -28,7 +28,6 @@ async fn config(conn: web::Data, session: Session) -> Result Date: Fri, 8 Dec 2023 12:51:56 -0500 Subject: [PATCH 04/24] refactor: make plugin manager more generic --- server/algorithms/src/clustering/greedy.rs | 4 +- server/algorithms/src/lib.rs | 1 + server/algorithms/src/plugin.rs | 191 ++++++++++++++ server/algorithms/src/routing/join.rs | 69 +++++ server/algorithms/src/routing/mod.rs | 14 +- .../algorithms/src/routing/plugin_manager.rs | 244 ------------------ server/model/src/api/args.rs | 8 +- 7 files changed, 273 insertions(+), 258 deletions(-) create mode 100644 server/algorithms/src/plugin.rs create mode 100644 server/algorithms/src/routing/join.rs delete mode 100644 server/algorithms/src/routing/plugin_manager.rs diff --git a/server/algorithms/src/clustering/greedy.rs b/server/algorithms/src/clustering/greedy.rs index 3552430b..da02d729 100644 --- a/server/algorithms/src/clustering/greedy.rs +++ b/server/algorithms/src/clustering/greedy.rs @@ -31,7 +31,7 @@ impl Default for Greedy { fn default() -> Self { Greedy { cluster_mode: ClusterMode::Balanced, - cluster_split_level: 1, + cluster_split_level: 0, max_clusters: usize::MAX, min_points: 1, radius: 70., @@ -65,7 +65,7 @@ impl<'a> Greedy { let time = Instant::now(); log::info!("starting algorithm with {} data points", points.len()); - let return_set = if self.cluster_split_level == 1 { + let return_set = if self.cluster_split_level == 0 { self.setup(points) } else { let cell_maps = s2::create_cell_map(&points, self.cluster_split_level); diff --git a/server/algorithms/src/lib.rs b/server/algorithms/src/lib.rs index 9c3a5737..d191d07f 100644 --- a/server/algorithms/src/lib.rs +++ b/server/algorithms/src/lib.rs @@ -2,6 +2,7 @@ use model; pub mod bootstrap; pub mod clustering; +mod plugin; mod project; pub mod routing; mod rtree; diff --git a/server/algorithms/src/plugin.rs b/server/algorithms/src/plugin.rs new file mode 100644 index 00000000..bf892d84 --- /dev/null +++ b/server/algorithms/src/plugin.rs @@ -0,0 +1,191 @@ +use std::fmt::Display; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::Instant; + +use crate::s2::create_cell_map; +use crate::utils; +use model::api::single_vec::SingleVec; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; + +#[derive(Debug)] +pub enum Folder { + Routing, + // Sorting, + // Bootstrap, +} + +impl Display for Folder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Folder::Routing => write!(f, "routing"), + // Folder::Sorting => write!(f, "sorting"), + // Folder::Bootstrap => write!(f, "bootstrap"), + } + } +} + +#[derive(Debug)] +pub struct Plugin { + plugin_path: String, + interpreter: String, + radius: f64, + pub plugin: String, + pub split_level: u64, +} + +pub type JoinFunction = fn(&Plugin, Vec) -> SingleVec; + +impl Plugin { + pub fn new( + plugin: &str, + folder: Folder, + route_split_level: u64, + radius: f64, + ) -> std::io::Result { + let path = format!("algorithms/src/{folder}/plugins/{plugin}"); + let path = Path::new(path.as_str()); + let plugin_path = if path.exists() { + path.display().to_string() + } else { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!( + "{plugin} does not exist{}", + if plugin == "tsp" { + ", rerun the OR Tools Script" + } else { + "" + } + ), + )); + }; + + let interpreter = match plugin.split(".").last() { + Some("py") => "python3", + Some("js") => "node", + Some("ts") => "ts-node", + val => { + if plugin == val.unwrap_or("") { + "" + } else { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Unrecognized plugin, please create a PR to add support for it", + )); + } + } + }; + Ok(Plugin { + plugin: plugin.to_string(), + plugin_path, + interpreter: interpreter.to_string(), + split_level: route_split_level, + radius, + }) + } + + pub fn run(&self, points: &SingleVec, joiner: Option) -> Result + where + T: Fn(&Self, Vec) -> SingleVec, + { + let handlers = if self.split_level == 0 { + vec![self.spawn(&points)?] + } else { + create_cell_map(&points, self.split_level) + .into_values() + .collect::>() + .into_par_iter() + .filter_map(|x| self.spawn(&x).ok()) + .collect() + }; + if let Some(joiner) = joiner { + Ok(joiner(self, handlers)) + } else { + Ok(handlers.into_iter().flatten().collect()) + } + } + + fn spawn(&self, points: &SingleVec) -> Result { + log::info!("spawning {} child process", self.plugin); + + let time = Instant::now(); + let stringified_points = utils::stringify_points(&points); + + let mut child = if self.interpreter.is_empty() { + Command::new(&self.plugin_path) + } else { + Command::new(&self.interpreter) + }; + if !self.interpreter.is_empty() { + child.arg(&self.plugin_path); + } + let mut child = match child + .args(&["--input", &stringified_points]) + .args(&["--radius", &self.radius.to_string()]) + .args(&["--route_split_level", &self.split_level.to_string()]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(err) => return Err(err), + }; + + let mut stdin = match child.stdin.take() { + Some(stdin) => stdin, + None => { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "failed to open stdin", + )); + } + }; + + match stdin.write_all(stringified_points.as_bytes()) { + Ok(_) => match stdin.flush() { + Ok(_) => {} + Err(err) => { + log::error!("failed to flush stdin: {}", err); + } + }, + Err(err) => { + log::error!("failed to write to stdin: {}", err) + } + }; + + let output = match child.wait_with_output() { + Ok(result) => result, + Err(err) => return Err(err), + }; + let output = String::from_utf8_lossy(&output.stdout); + let mut output_indexes = output + .split(",") + .filter_map(|s| s.trim().parse::().ok()) + .collect::>(); + if let Some(first) = output_indexes.first() { + if let Some(last) = output_indexes.last() { + if first == last { + output_indexes.pop(); + } + } + } + if output_indexes.is_empty() { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!( + "no valid output from child process {}, output should return comma separated indexes of the input clusters in the order they should be routed", + output + ), + )) + } else { + log::info!( + "{} child process finished in {}s", + self.plugin, + time.elapsed().as_secs_f32() + ); + Ok(output_indexes.into_iter().map(|i| points[i]).collect()) + } + } +} diff --git a/server/algorithms/src/routing/join.rs b/server/algorithms/src/routing/join.rs new file mode 100644 index 00000000..24b7d4d1 --- /dev/null +++ b/server/algorithms/src/routing/join.rs @@ -0,0 +1,69 @@ +use crate::plugin::{JoinFunction, Plugin}; +use crate::utils; +use geo::{HaversineDistance, Point}; +use model::api::{point_array::PointArray, single_vec::SingleVec}; +use s2::cellid::CellID; +use s2::latlng::LatLng; +use std::collections::HashMap; +use std::time::Instant; + +pub fn join(plugin: &Plugin, input: Vec) -> SingleVec { + let time = Instant::now(); + let mut point_map = HashMap::::new(); + + let get_cell_id = |point: PointArray| { + CellID::from(LatLng::from_degrees(point[0], point[1])) + .parent(plugin.split_level) + .0 + }; + + let mut centroids = vec![]; + for points in input.iter() { + let center = utils::centroid(&points); + centroids.push(center); + point_map.insert(get_cell_id(center), points.clone()); + } + let clusters: Vec = plugin + .run::(¢roids, None) + .unwrap_or(vec![]) + .into_iter() + .filter_map(|c| { + let hash = get_cell_id(c); + point_map.remove(&hash) + }) + .collect(); + + let mut final_routes: SingleVec = vec![]; + + let last = clusters.len() - 1; + for (i, current) in clusters.clone().iter_mut().enumerate() { + let next: &SingleVec = if i == last { + clusters[0].as_ref() + } else { + clusters[i + 1].as_ref() + }; + + let mut shortest = std::f64::MAX; + let mut shortest_current_index = 0; + + for (current_index, current_point) in current.iter().enumerate() { + let current_point = Point::new(current_point[1], current_point[0]); + for (_next_index, next_point) in next.iter().enumerate() { + let next_point = Point::new(next_point[1], next_point[0]); + let distance = current_point.haversine_distance(&next_point); + if distance < shortest { + shortest = distance; + shortest_current_index = current_index; + } + } + } + current.rotate_left(shortest_current_index); + final_routes.append(current); + } + log::info!( + "joined {} routes in {}ms", + final_routes.len(), + time.elapsed().as_millis() + ); + final_routes +} diff --git a/server/algorithms/src/routing/mod.rs b/server/algorithms/src/routing/mod.rs index d2c74834..03b67c3c 100644 --- a/server/algorithms/src/routing/mod.rs +++ b/server/algorithms/src/routing/mod.rs @@ -2,17 +2,14 @@ use std::time::Instant; use model::api::{single_vec::SingleVec, sort_by::SortBy}; +use self::sorting::{SortGeohash, SortLatLng, SortPointCount, SortRandom, SortS2}; use crate::{ + plugin::{Folder, Plugin}, stats::Stats, utils::{get_plugin_list, rotate_to_best}, }; -use self::{ - plugin_manager::PluginManager, - sorting::{SortGeohash, SortLatLng, SortPointCount, SortRandom, SortS2}, -}; - -pub mod plugin_manager; +mod join; pub mod sorting; // pub mod vrp; @@ -33,8 +30,9 @@ pub fn main( SortBy::Random => clusters.sort_random(), SortBy::Unset => clusters, SortBy::Custom(plugin) => { - match PluginManager::new(plugin, route_split_level, radius, &clusters) { - Ok(plugin_manager) => match plugin_manager.run() { + let clusters = clusters.sort_s2(); + match Plugin::new(plugin, Folder::Routing, route_split_level, radius) { + Ok(plugin_manager) => match plugin_manager.run(&clusters, Some(join::join)) { Ok(sorted_clusters) => sorted_clusters, Err(e) => { log::error!("Error while running plugin: {}", e); diff --git a/server/algorithms/src/routing/plugin_manager.rs b/server/algorithms/src/routing/plugin_manager.rs deleted file mode 100644 index 26186ba7..00000000 --- a/server/algorithms/src/routing/plugin_manager.rs +++ /dev/null @@ -1,244 +0,0 @@ -use std::collections::HashMap; -use std::io::Write; -use std::path::Path; -use std::process::{Command, Stdio}; -use std::time::Instant; - -use geo::{HaversineDistance, Point}; -use s2::cellid::CellID; -use s2::latlng::LatLng; - -use crate::routing::sorting::SortS2; -use crate::s2::create_cell_map; -use crate::utils::{self, stringify_points}; -use model::api::{point_array::PointArray, single_vec::SingleVec}; - -#[derive(Debug)] -pub struct PluginManager<'a> { - plugin: String, - plugin_path: String, - interpreter: String, - route_split_level: u64, - radius: f64, - clusters: &'a SingleVec, -} - -impl<'a> PluginManager<'a> { - pub fn new( - plugin: &str, - route_split_level: u64, - radius: f64, - clusters: &'a SingleVec, - ) -> std::io::Result { - let path = Path::new("algorithms/src/routing/plugins"); - let path = path.join(plugin); - let plugin_path = if path.exists() { - path.display().to_string() - } else { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!( - "{plugin} does not exist{}", - if plugin == "tsp" { - ", rerun the OR Tools Script" - } else { - "" - } - ), - )); - }; - - let interpreter = match plugin.split(".").last() { - Some("py") => "python3", - Some("js") => "node", - Some("ts") => "ts-node", - val => { - if plugin == val.unwrap_or("") { - "" - } else { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "Unrecognized plugin, please create a PR to add support for it", - )); - } - } - }; - Ok(PluginManager { - plugin: plugin.to_string(), - plugin_path, - interpreter: interpreter.to_string(), - route_split_level, - radius, - clusters, - }) - } - - pub fn run(self) -> Result { - log::info!("starting {}...", self.plugin); - let time = Instant::now(); - - if self.route_split_level < 2 || self.plugin != "tsp" { - return self.spawn_child_process(self.clusters); - } - let get_cell_id = |point: PointArray| { - CellID::from(LatLng::from_degrees(point[0], point[1])) - .parent(self.route_split_level) - .0 - }; - let merged_routes: Vec<(PointArray, SingleVec)> = - create_cell_map(&self.clusters, self.route_split_level as u64) - .into_iter() - .enumerate() - .map(|(i, (cell_id, segment))| { - log::debug!("Creating thread: {} for hash {}", i + 1, cell_id); - let mut route = self.spawn_child_process(&segment).unwrap_or(vec![]); - if let Some(last) = route.last() { - if let Some(first) = route.first() { - if first == last { - route.pop(); - } - } - } - ( - if route.len() > 0 { - utils::centroid(&route) - } else { - [0., 0.] - }, - route, - ) - }) - .collect(); - let mut centroids = vec![]; - - let mut point_map = HashMap::::new(); - merged_routes - .into_iter() - .enumerate() - .for_each(|(_i, (hash, r))| { - centroids.push(hash); - point_map.insert(get_cell_id(hash), r); - }); - - let clusters: Vec = self - .spawn_child_process(¢roids)? - .into_iter() - .filter_map(|c| { - let hash = get_cell_id(c); - point_map.remove(&hash) - }) - .collect(); - - let mut final_routes: SingleVec = vec![]; - - for (i, current) in clusters.clone().iter_mut().enumerate() { - let next: &SingleVec = if i == clusters.len() - 1 { - clusters[0].as_ref() - } else { - clusters[i + 1].as_ref() - }; - - let mut shortest = std::f64::MAX; - let mut shortest_current_index = 0; - - for (current_index, current_point) in current.iter().enumerate() { - let current_point = Point::new(current_point[1], current_point[0]); - for (_next_index, next_point) in next.iter().enumerate() { - let next_point = Point::new(next_point[1], next_point[0]); - let distance = current_point.haversine_distance(&next_point); - if distance < shortest { - shortest = distance; - shortest_current_index = current_index; - } - } - } - current.rotate_left(shortest_current_index); - final_routes.append(current); - } - - log::info!( - "full {} time: {}", - self.plugin, - time.elapsed().as_secs_f32() - ); - Ok(final_routes) - } - - fn spawn_child_process(&self, points: &SingleVec) -> Result { - log::info!("spawning {} child process", self.plugin); - let time = Instant::now(); - let clusters = points.clone().sort_s2(); - let stringified_points = stringify_points(&clusters); - - let mut child = if self.interpreter.is_empty() { - Command::new(&self.plugin_path) - } else { - Command::new(&self.interpreter) - }; - if !self.interpreter.is_empty() { - child.arg(&self.plugin_path); - } - let mut child = match child - .args(&["--input", &stringified_points]) - .args(&["--radius", &self.radius.to_string()]) - .args(&["--route_split_level", &self.route_split_level.to_string()]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - { - Ok(child) => child, - Err(err) => return Err(err), - }; - - let mut stdin = match child.stdin.take() { - Some(stdin) => stdin, - None => { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "failed to open stdin", - )); - } - }; - - std::thread::spawn( - move || match stdin.write_all(stringified_points.as_bytes()) { - Ok(_) => match stdin.flush() { - Ok(_) => {} - Err(err) => { - log::error!("failed to flush stdin: {}", err); - } - }, - Err(err) => { - log::error!("failed to write to stdin: {}", err) - } - }, - ); - - let output = match child.wait_with_output() { - Ok(result) => result, - Err(err) => return Err(err), - }; - let output = String::from_utf8_lossy(&output.stdout); - let output_indexes = output - .split(",") - .filter_map(|s| s.trim().parse::().ok()) - .collect::>(); - - if output_indexes.is_empty() { - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!( - "no valid output from child process {}, output should return comma separated indexes of the input clusters in the order they should be routed", - output - ), - )) - } else { - log::info!( - "{} child process finished in {}s", - self.plugin, - time.elapsed().as_secs_f32() - ); - Ok(output_indexes.into_iter().map(|i| clusters[i]).collect()) - } - } -} diff --git a/server/model/src/api/args.rs b/server/model/src/api/args.rs index 5c82e425..e839f71b 100644 --- a/server/model/src/api/args.rs +++ b/server/model/src/api/args.rs @@ -381,18 +381,18 @@ pub struct ArgsUnwrapped { fn validate_s2_cell(value_to_check: Option, label: &str) -> u64 { if let Some(cell_level) = value_to_check { - if cell_level.lt(&20) && cell_level.gt(&0) { + if cell_level.le(&20) && cell_level.ge(&0) { cell_level } else { log::warn!( - "{} only supports 1-20, {} was provided, defaulting to 1", + "{} only supports 0-20, {} was provided, defaulting to 0", label, cell_level ); - 1 + 0 } } else { - 1 + 0 } } From 0797e0a50a24e922aaac3758e102f0d40810b44e Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 8 Dec 2023 13:27:49 -0500 Subject: [PATCH 05/24] feat: routing plugin args --- client/src/components/drawer/Drawing.tsx | 4 +- client/src/components/drawer/Routing.tsx | 17 +++++--- client/src/components/drawer/Settings.tsx | 10 ++--- .../src/components/drawer/inputs/NumInput.tsx | 42 ++++++++++--------- client/src/hooks/usePersist.ts | 6 ++- client/src/pages/map/popups/Point.tsx | 10 ++++- client/src/services/fetches.ts | 2 + server/algorithms/src/bootstrap/mod.rs | 5 ++- server/algorithms/src/bootstrap/radius.rs | 5 +-- server/algorithms/src/bootstrap/s2.rs | 5 +-- server/algorithms/src/plugin.rs | 18 ++++---- server/algorithms/src/routing/mod.rs | 3 +- server/api/src/public/v1/calculate.rs | 6 +++ server/model/src/api/args.rs | 8 ++++ 14 files changed, 89 insertions(+), 52 deletions(-) diff --git a/client/src/components/drawer/Drawing.tsx b/client/src/components/drawer/Drawing.tsx index 5a7f4bbf..cb60ec80 100644 --- a/client/src/components/drawer/Drawing.tsx +++ b/client/src/components/drawer/Drawing.tsx @@ -7,7 +7,7 @@ import { usePersist } from '@hooks/usePersist' import ListSubheader from '../styled/Subheader' import Toggle from './inputs/Toggle' import { MultiOptionList } from './inputs/MultiOptions' -import NumInput from './inputs/NumInput' +import UserTextInput from './inputs/NumInput' import { LineColorSelector } from './inputs/LineStringColor' export default function DrawingTab() { @@ -17,7 +17,7 @@ export default function DrawingTab() { Drawing - + - + Clustering - + - + - + @@ -125,7 +125,12 @@ export default function RoutingTab() { }} /> sort === sort_by)}> - + + + sort === sort_by) && sort_by !== 'tsp'} + > + diff --git a/client/src/components/drawer/Settings.tsx b/client/src/components/drawer/Settings.tsx index 32cba64d..990817a7 100644 --- a/client/src/components/drawer/Settings.tsx +++ b/client/src/components/drawer/Settings.tsx @@ -22,7 +22,7 @@ import { fetchWrapper } from '@services/fetches' import Toggle from './inputs/Toggle' import ListSubheader from '../styled/Subheader' -import NumInput from './inputs/NumInput' +import UserTextInput from './inputs/NumInput' export default function Settings() { const navigate = useNavigate() @@ -84,16 +84,16 @@ export default function Settings() { )} Max Area to Auto Calc (km²) - - - + + + {process.env.NODE_ENV === 'development' && ( <> Dev - + )} diff --git a/client/src/components/drawer/inputs/NumInput.tsx b/client/src/components/drawer/inputs/NumInput.tsx index 137b8830..dca1f650 100644 --- a/client/src/components/drawer/inputs/NumInput.tsx +++ b/client/src/components/drawer/inputs/NumInput.tsx @@ -6,47 +6,51 @@ import { fromCamelCase, fromSnakeCase } from '@services/utils' import { UsePersist, usePersist } from '@hooks/usePersist' import { OnlyType } from '@assets/types' -export default function NumInput< - T extends keyof Omit< - OnlyType, - 's2_level' | 's2_size' - >, +export default function UserTextInput< + U extends UsePersist[T], + T extends keyof Omit, 's2_level' | 's2_size'>, >({ field, label, + helperText, endAdornment, disabled = false, - min = 0, - max = 9999, + min, + max, }: { field: T label?: string + helperText?: string disabled?: boolean endAdornment?: string - min?: number - max?: number + min?: U extends number ? number : never + max?: U extends number ? number : never }) { const value = usePersist((s) => s[field]) + const isNumber = typeof value === 'number' + const finalLabel = + label ?? (field.includes('_') ? fromSnakeCase(field) : fromCamelCase(field)) + return ( - + {isNumber && } - usePersist.setState({ [field]: +target.value }) + usePersist.setState({ + [field]: isNumber ? +target.value : target.value, + }) } - sx={{ width: '35%' }} - inputProps={{ min, max }} + label={isNumber ? undefined : finalLabel} + sx={{ width: isNumber ? '35%' : '100%' }} + inputProps={{ min: min || 0, max: max || 9999 }} InputProps={{ endAdornment }} disabled={disabled} + helperText={helperText} /> ) diff --git a/client/src/hooks/usePersist.ts b/client/src/hooks/usePersist.ts index 64a9a84e..1d7298d2 100644 --- a/client/src/hooks/usePersist.ts +++ b/client/src/hooks/usePersist.ts @@ -72,6 +72,7 @@ export interface UsePersist { s2_level: typeof S2_CELL_LEVELS[number] s2_size: typeof BOOTSTRAP_LEVELS[number] max_clusters: number + routing_args: string // generations: number | '' // routing_time: number | '' // devices: number | '' @@ -116,8 +117,8 @@ export const usePersist = create( s2DisplayMode: 'none', s2FillMode: 'simple', radius: 70, - route_split_level: 1, - cluster_split_level: 10, + route_split_level: 0, + cluster_split_level: 0, // routing_chunk_size: 0, calculation_mode: 'Radius', s2_level: 15, @@ -153,6 +154,7 @@ export const usePersist = create( pokestopMaxAreaAutoCalc: 100, gymMaxAreaAutoCalc: 100, spawnpointMaxAreaAutoCalc: 100, + routing_args: '', setStore: (key, value) => set({ [key]: value }), }), { diff --git a/client/src/pages/map/popups/Point.tsx b/client/src/pages/map/popups/Point.tsx index 6fb4e93f..e7479da4 100644 --- a/client/src/pages/map/popups/Point.tsx +++ b/client/src/pages/map/popups/Point.tsx @@ -189,8 +189,13 @@ export function PointPopup({ id, lat, lon, type: geoType, dbRef }: Props) { disabled={loading} onClick={async () => { setLoading(true) - const { route_split_level, save_to_scanner, save_to_db, sort_by } = - usePersist.getState() + const { + route_split_level, + save_to_scanner, + save_to_db, + sort_by, + routing_args, + } = usePersist.getState() const { setStatic } = useStatic.getState() setStatic('loading', { [name]: null }) setStatic('totalLoadingTime', 0) @@ -212,6 +217,7 @@ export function PointPopup({ id, lat, lon, type: geoType, dbRef }: Props) { route_split_level, save_to_scanner, sort_by, + routing_args, }), }).then((res) => { if (res) { diff --git a/client/src/services/fetches.ts b/client/src/services/fetches.ts index 53b29282..87569885 100644 --- a/client/src/services/fetches.ts +++ b/client/src/services/fetches.ts @@ -133,6 +133,7 @@ export async function clusteringRouting({ s2_level, s2_size, max_clusters, + routing_args, } = usePersist.getState() const { geojson, setStatic, bounds } = useStatic.getState() const { add, activeRoute } = useShapes.getState().setters @@ -245,6 +246,7 @@ export async function clusteringRouting({ calculation_mode, s2_level, s2_size, + routing_args, }), }, ) diff --git a/server/algorithms/src/bootstrap/mod.rs b/server/algorithms/src/bootstrap/mod.rs index 90b33ce0..7953a481 100644 --- a/server/algorithms/src/bootstrap/mod.rs +++ b/server/algorithms/src/bootstrap/mod.rs @@ -15,6 +15,7 @@ pub fn main( s2_size: u8, route_split_level: u64, stats: &mut Stats, + routing_args: &str, ) -> Vec { let mut features = vec![]; @@ -22,14 +23,14 @@ pub fn main( match calculation_mode { CalculationMode::Radius => { let mut new_radius = radius::BootstrapRadius::new(&feature, radius); - new_radius.sort(&sort_by, route_split_level); + new_radius.sort(&sort_by, route_split_level, routing_args); *stats += &new_radius.stats; features.push(new_radius.feature()); } CalculationMode::S2 => { let mut new_s2 = s2::BootstrapS2::new(&feature, s2_level as u64, s2_size); - new_s2.sort(&sort_by, route_split_level); + new_s2.sort(&sort_by, route_split_level, routing_args); *stats += &new_s2.stats; features.push(new_s2.feature()); diff --git a/server/algorithms/src/bootstrap/radius.rs b/server/algorithms/src/bootstrap/radius.rs index 227cf5ce..d89e38fd 100644 --- a/server/algorithms/src/bootstrap/radius.rs +++ b/server/algorithms/src/bootstrap/radius.rs @@ -37,8 +37,7 @@ impl<'a> BootstrapRadius<'a> { new_bootstrap } - pub fn sort(&mut self, sort_by: &SortBy, route_split_level: u64) { - let time = Instant::now(); + pub fn sort(&mut self, sort_by: &SortBy, route_split_level: u64, routing_args: &str) { self.result = routing::main( &vec![], self.result.clone(), @@ -46,8 +45,8 @@ impl<'a> BootstrapRadius<'a> { route_split_level, self.radius, &mut self.stats, + routing_args, ); - self.stats.set_route_time(time); } pub fn result(self) -> SingleVec { diff --git a/server/algorithms/src/bootstrap/s2.rs b/server/algorithms/src/bootstrap/s2.rs index 2c68175f..4bff20ed 100644 --- a/server/algorithms/src/bootstrap/s2.rs +++ b/server/algorithms/src/bootstrap/s2.rs @@ -48,8 +48,7 @@ impl<'a> BootstrapS2<'a> { new_bootstrap } - pub fn sort(&mut self, sort_by: &SortBy, route_split_level: u64) { - let time = Instant::now(); + pub fn sort(&mut self, sort_by: &SortBy, route_split_level: u64, routing_args: &str) { self.result = routing::main( &vec![], self.result.clone(), @@ -57,8 +56,8 @@ impl<'a> BootstrapS2<'a> { route_split_level, 0., &mut self.stats, + routing_args, ); - self.stats.set_route_time(time); } pub fn result(self) -> SingleVec { diff --git a/server/algorithms/src/plugin.rs b/server/algorithms/src/plugin.rs index bf892d84..d51815cd 100644 --- a/server/algorithms/src/plugin.rs +++ b/server/algorithms/src/plugin.rs @@ -12,7 +12,7 @@ use rayon::iter::{IntoParallelIterator, ParallelIterator}; #[derive(Debug)] pub enum Folder { Routing, - // Sorting, + // Clustering, // Bootstrap, } @@ -20,7 +20,7 @@ impl Display for Folder { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Folder::Routing => write!(f, "routing"), - // Folder::Sorting => write!(f, "sorting"), + // Folder::Clustering => write!(f, "clustering"), // Folder::Bootstrap => write!(f, "bootstrap"), } } @@ -30,7 +30,7 @@ impl Display for Folder { pub struct Plugin { plugin_path: String, interpreter: String, - radius: f64, + args: Vec, pub plugin: String, pub split_level: u64, } @@ -42,7 +42,7 @@ impl Plugin { plugin: &str, folder: Folder, route_split_level: u64, - radius: f64, + routing_args: &str, ) -> std::io::Result { let path = format!("algorithms/src/{folder}/plugins/{plugin}"); let path = Path::new(path.as_str()); @@ -82,7 +82,10 @@ impl Plugin { plugin_path, interpreter: interpreter.to_string(), split_level: route_split_level, - radius, + args: routing_args + .split_ascii_whitespace() + .map(|s| s.to_string()) + .collect::>(), }) } @@ -121,10 +124,11 @@ impl Plugin { if !self.interpreter.is_empty() { child.arg(&self.plugin_path); } + for arg in self.args.iter() { + child.arg(arg); + } let mut child = match child .args(&["--input", &stringified_points]) - .args(&["--radius", &self.radius.to_string()]) - .args(&["--route_split_level", &self.split_level.to_string()]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() diff --git a/server/algorithms/src/routing/mod.rs b/server/algorithms/src/routing/mod.rs index 03b67c3c..1a8f9fe3 100644 --- a/server/algorithms/src/routing/mod.rs +++ b/server/algorithms/src/routing/mod.rs @@ -20,6 +20,7 @@ pub fn main( route_split_level: u64, radius: f64, stats: &mut Stats, + routing_args: &str, ) -> SingleVec { let route_time = Instant::now(); let clusters = match sort_by { @@ -31,7 +32,7 @@ pub fn main( SortBy::Unset => clusters, SortBy::Custom(plugin) => { let clusters = clusters.sort_s2(); - match Plugin::new(plugin, Folder::Routing, route_split_level, radius) { + match Plugin::new(plugin, Folder::Routing, route_split_level, routing_args) { Ok(plugin_manager) => match plugin_manager.run(&clusters, Some(join::join)) { Ok(sorted_clusters) => sorted_clusters, Err(e) => { diff --git a/server/api/src/public/v1/calculate.rs b/server/api/src/public/v1/calculate.rs index e39377d0..f3270afb 100644 --- a/server/api/src/public/v1/calculate.rs +++ b/server/api/src/public/v1/calculate.rs @@ -36,6 +36,7 @@ async fn bootstrap( parent, sort_by, route_split_level, + routing_args, .. } = payload.into_inner().init(Some("bootstrap")); @@ -60,6 +61,7 @@ async fn bootstrap( s2_size, route_split_level, &mut stats, + &routing_args, ); if parent.is_some() { @@ -162,6 +164,7 @@ async fn cluster( sort_by, tth, route_split_level, + routing_args, calculation_mode, s2_level, s2_size, @@ -241,6 +244,7 @@ async fn cluster( route_split_level, radius, &mut stats, + &routing_args, ); let mut feature = clusters @@ -309,6 +313,7 @@ async fn reroute(payload: web::Json) -> Result { mode, sort_by, radius, + routing_args, .. } = payload.into_inner().init(Some("reroute")); let mut stats = Stats::new(String::from("Reroute"), 1); @@ -328,6 +333,7 @@ async fn reroute(payload: web::Json) -> Result { route_split_level, radius, &mut stats, + &routing_args, ); let feature = clusters.to_feature(Some(mode.clone())).remove_last_coord(); diff --git a/server/model/src/api/args.rs b/server/model/src/api/args.rs index e839f71b..3be845db 100644 --- a/server/model/src/api/args.rs +++ b/server/model/src/api/args.rs @@ -299,6 +299,10 @@ pub struct Args { /// /// Deprecated pub route_chunk_size: Option, + /// Args to be applied to a custom routing plugin + /// + /// Default: `''` + pub routing_args: Option, /// Geohash precision level for splitting up routing into multiple threads /// /// Recommend using 4 for Gyms, 5 for Pokestops, and 6 for Spawnpoints @@ -377,6 +381,7 @@ pub struct ArgsUnwrapped { pub tth: SpawnpointTth, pub mode: Type, pub route_split_level: u64, + pub routing_args: String, } fn validate_s2_cell(value_to_check: Option, label: &str) -> u64 { @@ -445,6 +450,7 @@ impl Args { tth, mode, route_split_level, + routing_args, } = self; let enum_type = get_enum_by_geometry_string(geometry_type); let (area, default_return_type) = if let Some(area) = area { @@ -518,6 +524,7 @@ impl Args { let tth = tth.unwrap_or(SpawnpointTth::All); let mode = get_enum(mode); let route_split_level = validate_s2_cell(route_split_level, "route_split_level"); + let routing_args = routing_args.unwrap_or("".to_string()); if route_chunk_size.is_some() { log::warn!("route_chunk_size is now deprecated, please use route_split_level") } @@ -553,6 +560,7 @@ impl Args { tth, mode, route_split_level, + routing_args, } } } From d23a2b1c381acf6f415d2812603b95a42f3959f0 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 8 Dec 2023 14:36:01 -0500 Subject: [PATCH 06/24] feat: clustering plugin support --- .gitignore | 4 +-- client/src/App.tsx | 1 + client/src/assets/types.ts | 1 + client/src/components/drawer/Routing.tsx | 27 +++++++++----- client/src/hooks/usePersist.ts | 4 ++- client/src/hooks/useStatic.ts | 2 ++ client/src/services/fetches.ts | 2 ++ server/algorithms/src/clustering/mod.rs | 35 +++++++++++++++++-- .../src/clustering/plugins/.gitkeep | 0 server/algorithms/src/plugin.rs | 6 ++-- server/algorithms/src/routing/mod.rs | 6 ++-- server/api/src/private/misc.rs | 4 ++- server/api/src/public/v1/calculate.rs | 2 ++ server/api/src/utils/response.rs | 1 + server/model/src/api/args.rs | 8 +++++ server/model/src/api/cluster_mode.rs | 7 ++-- 16 files changed, 85 insertions(+), 25 deletions(-) create mode 100644 server/algorithms/src/clustering/plugins/.gitkeep diff --git a/.gitignore b/.gitignore index f466f873..8bbae861 100644 --- a/.gitignore +++ b/.gitignore @@ -15,8 +15,8 @@ docker-compose.yml server/target vrp_tests server/debug_files/* -server/algorithms/src/routing/plugins/**/* -!server/algorithms/src/routing/plugins/.gitkeep +server/algorithms/src/**/plugins/**/* +!server/algorithms/src/**/plugins/.gitkeep # misc .idea/* diff --git a/client/src/App.tsx b/client/src/App.tsx index f9e2fa42..dcee057b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -83,6 +83,7 @@ export default function App() { setStatic('scannerType', res.scanner_type) setStatic('dangerous', res.dangerous || false) setStatic('route_plugins', res.route_plugins || []) + setStatic('clustering_plugins', res.clustering_plugins || []) if (!res.logged_in) { router.navigate('/login') } diff --git a/client/src/assets/types.ts b/client/src/assets/types.ts index bec0daa8..b4930042 100644 --- a/client/src/assets/types.ts +++ b/client/src/assets/types.ts @@ -215,6 +215,7 @@ export interface Config { logged_in: boolean dangerous: boolean route_plugins: string[] + clustering_plugins: string[] } export type CombinedState = Partial & Partial diff --git a/client/src/components/drawer/Routing.tsx b/client/src/components/drawer/Routing.tsx index 271a524a..be695355 100644 --- a/client/src/components/drawer/Routing.tsx +++ b/client/src/components/drawer/Routing.tsx @@ -28,6 +28,14 @@ import UserTextInput from './inputs/NumInput' import { MultiOptionList } from './inputs/MultiOptions' import Toggle from './inputs/Toggle' +const formatPluginName = (item: string) => { + if (item === 'tsp') return 'TSP' + if (item.includes('.')) { + const [plugin, ext] = item.split('.') + return `${plugin} (${ext})` + } + return item +} export default function RoutingTab() { const mode = usePersist((s) => s.mode) const category = usePersist((s) => s.category) @@ -41,10 +49,14 @@ export default function RoutingTab() { Object.values(s.layerEditing).some((v) => v), ) const routePlugins = useStatic((s) => s.route_plugins) + const clusteringPlugins = useStatic((s) => s.clustering_plugins) const sortByOptions = React.useMemo(() => { return [...SORT_BY, ...routePlugins] }, [routePlugins]) + const clusterOptions = React.useMemo(() => { + return [...CLUSTERING_MODES, ...clusteringPlugins] + }, [clusteringPlugins]) const fastest = cluster_mode === 'Fastest' return ( @@ -98,8 +110,9 @@ export default function RoutingTab() { @@ -107,6 +120,9 @@ export default function RoutingTab() { + m === cluster_mode)}> + + @@ -115,14 +131,7 @@ export default function RoutingTab() { field="sort_by" buttons={sortByOptions} type="select" - itemLabel={(item) => { - if (item === 'tsp') return 'TSP' - if (item.includes('.')) { - const [plugin, ext] = item.split('.') - return `${plugin} (${ext})` - } - return item - }} + itemLabel={formatPluginName} /> sort === sort_by)}> diff --git a/client/src/hooks/usePersist.ts b/client/src/hooks/usePersist.ts index 1d7298d2..43fe0f88 100644 --- a/client/src/hooks/usePersist.ts +++ b/client/src/hooks/usePersist.ts @@ -55,7 +55,7 @@ export interface UsePersist { // Clustering category: Category | 'fort' - cluster_mode: typeof CLUSTERING_MODES[number] + cluster_mode: typeof CLUSTERING_MODES[number] | string tth: typeof TTH[number] lineColorRules: { distance: number; color: string }[] mode: typeof MODES[number] @@ -73,6 +73,7 @@ export interface UsePersist { s2_size: typeof BOOTSTRAP_LEVELS[number] max_clusters: number routing_args: string + clustering_args: string // generations: number | '' // routing_time: number | '' // devices: number | '' @@ -155,6 +156,7 @@ export const usePersist = create( gymMaxAreaAutoCalc: 100, spawnpointMaxAreaAutoCalc: 100, routing_args: '', + clustering_args: '', setStore: (key, value) => set({ [key]: value }), }), { diff --git a/client/src/hooks/useStatic.ts b/client/src/hooks/useStatic.ts index f121d1e2..63639fc4 100644 --- a/client/src/hooks/useStatic.ts +++ b/client/src/hooks/useStatic.ts @@ -33,6 +33,7 @@ export interface UseStatic { totalLoadingTime: number selected: string[] route_plugins: string[] + clustering_plugins: string[] tileServers: KojiTileServer[] kojiRoutes: { name: string; id: number; type: string }[] scannerRoutes: { name: string; id: number; type: string }[] @@ -112,6 +113,7 @@ export const useStatic = create((set, get) => ({ features: [], }, route_plugins: [], + clustering_plugins: [], layerEditing: { cutMode: false, dragMode: false, diff --git a/client/src/services/fetches.ts b/client/src/services/fetches.ts index 87569885..b5e598c3 100644 --- a/client/src/services/fetches.ts +++ b/client/src/services/fetches.ts @@ -134,6 +134,7 @@ export async function clusteringRouting({ s2_size, max_clusters, routing_args, + clustering_args, } = usePersist.getState() const { geojson, setStatic, bounds } = useStatic.getState() const { add, activeRoute } = useShapes.getState().setters @@ -247,6 +248,7 @@ export async function clusteringRouting({ s2_level, s2_size, routing_args, + clustering_args, }), }, ) diff --git a/server/algorithms/src/clustering/mod.rs b/server/algorithms/src/clustering/mod.rs index 050847c5..60ba8c5d 100644 --- a/server/algorithms/src/clustering/mod.rs +++ b/server/algorithms/src/clustering/mod.rs @@ -1,6 +1,10 @@ use std::{time::Instant, vec}; -use crate::stats::Stats; +use crate::{ + plugin::{Folder, JoinFunction, Plugin}, + stats::Stats, + utils, +}; use self::greedy::Greedy; @@ -25,6 +29,7 @@ pub fn main( s2_level: u8, s2_size: u8, collection: FeatureCollection, + clustering_args: &str, ) -> SingleVec { if data_points.is_empty() { return vec![]; @@ -40,7 +45,7 @@ pub fn main( let clusters = fastest::main(&data_points, radius, min_points); clusters } - _ => { + ClusterMode::Balanced | ClusterMode::Fast | ClusterMode::Better | ClusterMode::Best => { let mut greedy = Greedy::default(); greedy .set_cluster_mode(cluster_mode) @@ -51,6 +56,28 @@ pub fn main( greedy.run(&data_points) } + ClusterMode::Custom(plugin) => { + match Plugin::new( + &plugin, + Folder::Clustering, + cluster_split_level, + clustering_args, + ) { + Ok(plugin_manager) => { + match plugin_manager.run::(data_points, None) { + Ok(sorted_clusters) => sorted_clusters, + Err(e) => { + log::error!("Error while running plugin: {}", e); + vec![] + } + } + } + Err(e) => { + log::error!("Plugin not found: {}", e); + vec![] + } + } + } }, }; @@ -60,3 +87,7 @@ pub fn main( clusters } + +pub fn clustering_plugins() -> Vec { + utils::get_plugin_list("algorithms/src/clustering/plugins").unwrap_or(vec![]) +} diff --git a/server/algorithms/src/clustering/plugins/.gitkeep b/server/algorithms/src/clustering/plugins/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/server/algorithms/src/plugin.rs b/server/algorithms/src/plugin.rs index d51815cd..5cd6592a 100644 --- a/server/algorithms/src/plugin.rs +++ b/server/algorithms/src/plugin.rs @@ -12,7 +12,7 @@ use rayon::iter::{IntoParallelIterator, ParallelIterator}; #[derive(Debug)] pub enum Folder { Routing, - // Clustering, + Clustering, // Bootstrap, } @@ -20,7 +20,7 @@ impl Display for Folder { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Folder::Routing => write!(f, "routing"), - // Folder::Clustering => write!(f, "clustering"), + Folder::Clustering => write!(f, "clustering"), // Folder::Bootstrap => write!(f, "bootstrap"), } } @@ -179,7 +179,7 @@ impl Plugin { Err(std::io::Error::new( std::io::ErrorKind::NotFound, format!( - "no valid output from child process {}, output should return comma separated indexes of the input clusters in the order they should be routed", + "no valid output from child process \n{}\noutput should return comma separated indexes of the input clusters in the order they should be routed", output ), )) diff --git a/server/algorithms/src/routing/mod.rs b/server/algorithms/src/routing/mod.rs index 1a8f9fe3..48577f94 100644 --- a/server/algorithms/src/routing/mod.rs +++ b/server/algorithms/src/routing/mod.rs @@ -6,7 +6,7 @@ use self::sorting::{SortGeohash, SortLatLng, SortPointCount, SortRandom, SortS2} use crate::{ plugin::{Folder, Plugin}, stats::Stats, - utils::{get_plugin_list, rotate_to_best}, + utils, }; mod join; @@ -47,7 +47,7 @@ pub fn main( } } }; - let clusters = rotate_to_best(clusters, stats); + let clusters = utils::rotate_to_best(clusters, stats); stats.set_route_time(route_time); stats.distance_stats(&clusters); @@ -56,5 +56,5 @@ pub fn main( } pub fn routing_plugins() -> Vec { - get_plugin_list("algorithms/src/routing/plugins").unwrap_or(vec![]) + utils::get_plugin_list("algorithms/src/routing/plugins").unwrap_or(vec![]) } diff --git a/server/api/src/private/misc.rs b/server/api/src/private/misc.rs index 2adba202..2281a694 100644 --- a/server/api/src/private/misc.rs +++ b/server/api/src/private/misc.rs @@ -8,7 +8,7 @@ use crate::{ use actix_session::Session; use actix_web::http::header; -use algorithms::routing; +use algorithms::{clustering, routing}; use geojson::Value; use model::{api::args::Auth, KojiDb}; use serde_json::json; @@ -27,6 +27,7 @@ async fn config(conn: web::Data, session: Session) -> Result, session: Session) -> Result, + pub clustering_plugins: Vec, } #[derive(Debug, Serialize, Clone)] diff --git a/server/model/src/api/args.rs b/server/model/src/api/args.rs index 3be845db..f17ca392 100644 --- a/server/model/src/api/args.rs +++ b/server/model/src/api/args.rs @@ -213,6 +213,10 @@ pub struct Args { /// /// Default: `0` pub calculation_mode: Option, + /// Args to be applied to a custom routing plugin + /// + /// Default: `''` + pub clustering_args: Option, /// Cluster mode selection /// /// Accepts [ClusterMode] @@ -382,6 +386,7 @@ pub struct ArgsUnwrapped { pub mode: Type, pub route_split_level: u64, pub routing_args: String, + pub clustering_args: String, } fn validate_s2_cell(value_to_check: Option, label: &str) -> u64 { @@ -451,6 +456,7 @@ impl Args { mode, route_split_level, routing_args, + clustering_args, } = self; let enum_type = get_enum_by_geometry_string(geometry_type); let (area, default_return_type) = if let Some(area) = area { @@ -525,6 +531,7 @@ impl Args { let mode = get_enum(mode); let route_split_level = validate_s2_cell(route_split_level, "route_split_level"); let routing_args = routing_args.unwrap_or("".to_string()); + let clustering_args = clustering_args.unwrap_or("".to_string()); if route_chunk_size.is_some() { log::warn!("route_chunk_size is now deprecated, please use route_split_level") } @@ -561,6 +568,7 @@ impl Args { mode, route_split_level, routing_args, + clustering_args, } } } diff --git a/server/model/src/api/cluster_mode.rs b/server/model/src/api/cluster_mode.rs index c9e0a067..0ed487d2 100644 --- a/server/model/src/api/cluster_mode.rs +++ b/server/model/src/api/cluster_mode.rs @@ -7,6 +7,7 @@ pub enum ClusterMode { Balanced, Better, Best, + Custom(String), } impl<'de> Deserialize<'de> for ClusterMode { @@ -29,10 +30,7 @@ impl<'de> Deserialize<'de> for ClusterMode { log::warn!("rtree is now deprecated, using `balanced` strategy instead"); Ok(ClusterMode::Balanced) } - _ => Err(serde::de::Error::custom(format!( - "unknown cluster mode: {}", - s - ))), + _ => Ok(ClusterMode::Custom(s)), } } } @@ -60,6 +58,7 @@ impl ToString for ClusterMode { ClusterMode::Balanced => "Balanced", ClusterMode::Better => "Better", ClusterMode::Best => "Best", + ClusterMode::Custom(s) => s, } .to_string() } From 06780a0a8d28c75bbb249200c87ade7d6a737a13 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 8 Dec 2023 17:34:58 -0500 Subject: [PATCH 07/24] feat: bootstrap plugin support --- .vscode/settings.json | 80 ++++++++++++++++++- client/src/App.tsx | 1 + client/src/assets/types.ts | 1 + client/src/components/drawer/Routing.tsx | 19 ++++- .../components/drawer/inputs/MultiOptions.tsx | 22 ++--- client/src/hooks/usePersist.ts | 4 +- client/src/hooks/useStatic.ts | 2 + client/src/services/fetches.ts | 2 + or-tools/tsp/tsp.cc | 38 ++++++--- server/algorithms/src/bootstrap/mod.rs | 39 ++++++++- .../algorithms/src/bootstrap/plugins/.gitkeep | 0 server/algorithms/src/clustering/mod.rs | 4 +- server/algorithms/src/plugin.rs | 57 ++++++++++--- server/algorithms/src/routing/join.rs | 2 +- server/algorithms/src/routing/mod.rs | 2 +- server/api/src/private/misc.rs | 4 +- server/api/src/public/v1/calculate.rs | 2 + server/api/src/utils/response.rs | 1 + server/model/src/api/args.rs | 18 +++-- server/model/src/api/calc_mode.rs | 23 ++++++ server/model/src/api/mod.rs | 1 + 21 files changed, 268 insertions(+), 54 deletions(-) create mode 100644 server/algorithms/src/bootstrap/plugins/.gitkeep create mode 100644 server/model/src/api/calc_mode.rs diff --git a/.vscode/settings.json b/.vscode/settings.json index dafb5b05..de3d9b04 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,7 +10,85 @@ "editor.formatOnSave": true, "rust-analyzer.showUnlinkedFileNotification": false, "files.associations": { - "vector": "cpp" + "vector": "cpp", + "__bit_reference": "cpp", + "__bits": "cpp", + "__config": "cpp", + "__debug": "cpp", + "__errc": "cpp", + "__hash_table": "cpp", + "__locale": "cpp", + "__mutex_base": "cpp", + "__node_handle": "cpp", + "__nullptr": "cpp", + "__split_buffer": "cpp", + "__string": "cpp", + "__threading_support": "cpp", + "__tree": "cpp", + "__tuple": "cpp", + "any": "cpp", + "array": "cpp", + "atomic": "cpp", + "bit": "cpp", + "bitset": "cpp", + "cctype": "cpp", + "chrono": "cpp", + "cinttypes": "cpp", + "clocale": "cpp", + "cmath": "cpp", + "compare": "cpp", + "complex": "cpp", + "concepts": "cpp", + "condition_variable": "cpp", + "csignal": "cpp", + "cstdarg": "cpp", + "cstddef": "cpp", + "cstdint": "cpp", + "cstdio": "cpp", + "cstdlib": "cpp", + "cstring": "cpp", + "ctime": "cpp", + "cwchar": "cpp", + "cwctype": "cpp", + "deque": "cpp", + "exception": "cpp", + "forward_list": "cpp", + "fstream": "cpp", + "future": "cpp", + "initializer_list": "cpp", + "iomanip": "cpp", + "ios": "cpp", + "iosfwd": "cpp", + "iostream": "cpp", + "istream": "cpp", + "limits": "cpp", + "list": "cpp", + "locale": "cpp", + "map": "cpp", + "memory": "cpp", + "mutex": "cpp", + "new": "cpp", + "numeric": "cpp", + "optional": "cpp", + "ostream": "cpp", + "queue": "cpp", + "random": "cpp", + "ratio": "cpp", + "set": "cpp", + "sstream": "cpp", + "stack": "cpp", + "stdexcept": "cpp", + "streambuf": "cpp", + "string": "cpp", + "string_view": "cpp", + "system_error": "cpp", + "tuple": "cpp", + "type_traits": "cpp", + "typeinfo": "cpp", + "unordered_map": "cpp", + "unordered_set": "cpp", + "variant": "cpp", + "algorithm": "cpp" }, "cmake.configureOnOpen": false } \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index dcee057b..c9c5de52 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -84,6 +84,7 @@ export default function App() { setStatic('dangerous', res.dangerous || false) setStatic('route_plugins', res.route_plugins || []) setStatic('clustering_plugins', res.clustering_plugins || []) + setStatic('bootstrap_plugins', res.bootstrap_plugins || false) if (!res.logged_in) { router.navigate('/login') } diff --git a/client/src/assets/types.ts b/client/src/assets/types.ts index b4930042..f455f32a 100644 --- a/client/src/assets/types.ts +++ b/client/src/assets/types.ts @@ -216,6 +216,7 @@ export interface Config { dangerous: boolean route_plugins: string[] clustering_plugins: string[] + bootstrap_plugins: string[] } export type CombinedState = Partial & Partial diff --git a/client/src/components/drawer/Routing.tsx b/client/src/components/drawer/Routing.tsx index be695355..2697b39c 100644 --- a/client/src/components/drawer/Routing.tsx +++ b/client/src/components/drawer/Routing.tsx @@ -50,6 +50,7 @@ export default function RoutingTab() { ) const routePlugins = useStatic((s) => s.route_plugins) const clusteringPlugins = useStatic((s) => s.clustering_plugins) + const bootstrapPlugins = useStatic((s) => s.bootstrap_plugins) const sortByOptions = React.useMemo(() => { return [...SORT_BY, ...routePlugins] @@ -57,6 +58,15 @@ export default function RoutingTab() { const clusterOptions = React.useMemo(() => { return [...CLUSTERING_MODES, ...clusteringPlugins] }, [clusteringPlugins]) + const bootstrapOptions = React.useMemo(() => { + return [...CALC_MODE, ...bootstrapPlugins] + }, [bootstrapPlugins]) + + React.useEffect(() => { + if (!CALC_MODE.some((x) => x === calculation_mode)) { + usePersist.setState({ calculation_mode: 'Radius' }) + } + }, [mode]) const fastest = cluster_mode === 'Fastest' return ( @@ -76,11 +86,18 @@ export default function RoutingTab() { + x === calculation_mode) && mode === 'bootstrap' + } + > + + diff --git a/client/src/components/drawer/inputs/MultiOptions.tsx b/client/src/components/drawer/inputs/MultiOptions.tsx index 24b13a94..94ea0b01 100644 --- a/client/src/components/drawer/inputs/MultiOptions.tsx +++ b/client/src/components/drawer/inputs/MultiOptions.tsx @@ -26,6 +26,13 @@ interface Props { itemLabel?: (item: K) => string } +const defaultLabel = (item: string | number) => + typeof item === 'string' + ? item.includes('_') + ? fromSnakeCase(item) + : fromCamelCase(item) + : `${item}` + export default function MultiOptions< T extends FieldType, K extends UsePersist[T], @@ -36,14 +43,9 @@ export default function MultiOptions< type = 'button', label = '', hideLabel = !label, - itemLabel = (item: number | string) => - typeof item === 'string' - ? item.includes('_') - ? fromSnakeCase(item) - : fromCamelCase(item) - : `${item}`, + itemLabel = defaultLabel, }: Props) { - const [value, setValue] = usePersist((s) => [s[field], usePersist.setState]) + const value = usePersist((s) => s[field]) return type === 'button' ? ( setValue({ [field]: v })} + onChange={(_e, v) => usePersist.setState({ [field]: v })} sx={{ mx: 'auto' }} disabled={disabled} > @@ -72,7 +74,9 @@ export default function MultiOptions< label={hideLabel ? undefined : label} value={value} color="primary" - onChange={({ target }) => setValue({ [field]: target.value as K })} // Mui y u like this + onChange={({ target }) => + usePersist.setState({ [field]: target.value as K }) + } // Mui y u like this sx={{ mx: 'auto', minWidth: 150 }} disabled={disabled} > diff --git a/client/src/hooks/usePersist.ts b/client/src/hooks/usePersist.ts index 43fe0f88..e38ae537 100644 --- a/client/src/hooks/usePersist.ts +++ b/client/src/hooks/usePersist.ts @@ -68,12 +68,13 @@ export interface UsePersist { save_to_scanner: boolean skipRendering: boolean fast: boolean - calculation_mode: typeof CALC_MODE[number] + calculation_mode: typeof CALC_MODE[number] | string s2_level: typeof S2_CELL_LEVELS[number] s2_size: typeof BOOTSTRAP_LEVELS[number] max_clusters: number routing_args: string clustering_args: string + bootstrap_args: string // generations: number | '' // routing_time: number | '' // devices: number | '' @@ -157,6 +158,7 @@ export const usePersist = create( spawnpointMaxAreaAutoCalc: 100, routing_args: '', clustering_args: '', + bootstrap_args: '', setStore: (key, value) => set({ [key]: value }), }), { diff --git a/client/src/hooks/useStatic.ts b/client/src/hooks/useStatic.ts index 63639fc4..a1e47b75 100644 --- a/client/src/hooks/useStatic.ts +++ b/client/src/hooks/useStatic.ts @@ -34,6 +34,7 @@ export interface UseStatic { selected: string[] route_plugins: string[] clustering_plugins: string[] + bootstrap_plugins: string[] tileServers: KojiTileServer[] kojiRoutes: { name: string; id: number; type: string }[] scannerRoutes: { name: string; id: number; type: string }[] @@ -114,6 +115,7 @@ export const useStatic = create((set, get) => ({ }, route_plugins: [], clustering_plugins: [], + bootstrap_plugins: [], layerEditing: { cutMode: false, dragMode: false, diff --git a/client/src/services/fetches.ts b/client/src/services/fetches.ts index b5e598c3..1f0471fa 100644 --- a/client/src/services/fetches.ts +++ b/client/src/services/fetches.ts @@ -135,6 +135,7 @@ export async function clusteringRouting({ max_clusters, routing_args, clustering_args, + bootstrap_args, } = usePersist.getState() const { geojson, setStatic, bounds } = useStatic.getState() const { add, activeRoute } = useShapes.getState().setters @@ -249,6 +250,7 @@ export async function clusteringRouting({ s2_size, routing_args, clustering_args, + bootstrap_args, }), }, ) diff --git a/or-tools/tsp/tsp.cc b/or-tools/tsp/tsp.cc index 82bd4be6..6c372151 100644 --- a/or-tools/tsp/tsp.cc +++ b/or-tools/tsp/tsp.cc @@ -15,6 +15,7 @@ typedef std::vector> DistanceMatrix; typedef std::vector> RawInput; +typedef std::vector RawOutput; namespace operations_research { @@ -96,17 +97,17 @@ namespace operations_research //! @param[in] manager The manager of the routing problem. //! @param[in] routing The routing model. //! @param[in] solution The solution of the routing problem. - RawInput GetRoutes(const RoutingIndexManager &manager, const RoutingModel &routing, const Assignment &solution) + RawOutput GetRoutes(const RoutingIndexManager &manager, const RoutingModel &routing, const Assignment &solution) { - RawInput routes(manager.num_vehicles()); - for (double vehicle_id = 0; vehicle_id < manager.num_vehicles(); ++vehicle_id) + RawOutput routes(manager.num_vehicles()); + for (int vehicle_id = 0; vehicle_id < manager.num_vehicles(); ++vehicle_id) { int64_t index = routing.Start(vehicle_id); - routes[vehicle_id].push_back(manager.IndexToNode(index).value()); + routes.push_back(manager.IndexToNode(index).value()); while (!routing.IsEnd(index)) { index = solution.Value(routing.NextVar(index)); - routes[vehicle_id].push_back(manager.IndexToNode(index).value()); + routes.push_back(manager.IndexToNode(index).value()); } } return routes; @@ -114,7 +115,7 @@ namespace operations_research //! @brief Solves the TSP problem. //! @param[in] locations The [Lat, Lng] pairs. - RawInput Tsp(RawInput locations) + RawOutput Tsp(RawInput locations) { DataModel data; data.distance_matrix = distanceMatrix(locations); @@ -165,10 +166,23 @@ std::vector split(const std::string &s, char delimiter) return tokens; } +double stodpre(std::string const &str, std::size_t const p) +{ + std::stringstream sstrm; + sstrm << std::setprecision(p) << std::fixed << str << std::endl; + + LOG(INFO) << "Stream: " << sstrm.str(); + double d; + sstrm >> d; + + return d; +} + int main(int argc, char *argv[]) { std::map args; RawInput points; + std::vector stringPoints; for (int i = 1; i < argc; ++i) { @@ -188,6 +202,7 @@ int main(int argc, char *argv[]) double lat = std::stod(coordinates[0]); double lng = std::stod(coordinates[1]); points.push_back({lat, lng}); + stringPoints.push_back(pointStr); } } } @@ -201,14 +216,11 @@ int main(int argc, char *argv[]) } } - RawInput routes = operations_research::Tsp(points); - for (auto route : routes) + RawOutput routes = operations_research::Tsp(points); + + for (auto point : routes) { - for (auto node : route) - { - std::cout << node << ","; - } - std::cout << std::endl; + std::cout << stringPoints[point] << " "; } return EXIT_SUCCESS; diff --git a/server/algorithms/src/bootstrap/mod.rs b/server/algorithms/src/bootstrap/mod.rs index 7953a481..acac3e4f 100644 --- a/server/algorithms/src/bootstrap/mod.rs +++ b/server/algorithms/src/bootstrap/mod.rs @@ -1,7 +1,13 @@ +use std::time::Instant; + use geojson::{Feature, FeatureCollection}; -use model::api::{args::CalculationMode, sort_by::SortBy, Precision}; +use model::api::{calc_mode::CalculationMode, sort_by::SortBy, Precision, ToFeature}; -use crate::stats::Stats; +use crate::{ + plugin::{Folder, Plugin}, + stats::Stats, + utils, +}; pub mod radius; pub mod s2; @@ -16,11 +22,12 @@ pub fn main( route_split_level: u64, stats: &mut Stats, routing_args: &str, + bootstrap_args: &str, ) -> Vec { let mut features = vec![]; for feature in area.features { - match calculation_mode { + match &calculation_mode { CalculationMode::Radius => { let mut new_radius = radius::BootstrapRadius::new(&feature, radius); new_radius.sort(&sort_by, route_split_level, routing_args); @@ -35,7 +42,33 @@ pub fn main( *stats += &new_s2.stats; features.push(new_s2.feature()); } + CalculationMode::Custom(plugin) => { + match Plugin::new(plugin, Folder::Bootstrap, 0, bootstrap_args) { + Ok(plugin_manager) => { + let time = Instant::now(); + match plugin_manager.run(feature.to_string()) { + Ok(sorted_clusters) => { + let mut plugin_stats = Stats::new(plugin.to_string(), 0); + plugin_stats.set_cluster_time(time); + plugin_stats.cluster_stats(0., &vec![], &sorted_clusters); + features.push(sorted_clusters.to_feature(None)); + *stats += &plugin_stats; + } + Err(e) => { + log::error!("Error while running plugin: {}", e); + } + } + } + Err(e) => { + log::error!("Plugin not found: {}", e); + } + } + } } } features } + +pub fn bootstrap_plugins() -> Vec { + utils::get_plugin_list("algorithms/src/bootstrap/plugins").unwrap_or(vec![]) +} diff --git a/server/algorithms/src/bootstrap/plugins/.gitkeep b/server/algorithms/src/bootstrap/plugins/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/server/algorithms/src/clustering/mod.rs b/server/algorithms/src/clustering/mod.rs index 60ba8c5d..d2e9d1aa 100644 --- a/server/algorithms/src/clustering/mod.rs +++ b/server/algorithms/src/clustering/mod.rs @@ -11,7 +11,7 @@ use self::greedy::Greedy; use super::*; use geojson::FeatureCollection; -use model::api::{args::CalculationMode, cluster_mode::ClusterMode, single_vec::SingleVec}; +use model::api::{calc_mode::CalculationMode, cluster_mode::ClusterMode, single_vec::SingleVec}; mod fastest; mod greedy; @@ -64,7 +64,7 @@ pub fn main( clustering_args, ) { Ok(plugin_manager) => { - match plugin_manager.run::(data_points, None) { + match plugin_manager.run_multi::(data_points, None) { Ok(sorted_clusters) => sorted_clusters, Err(e) => { log::error!("Error while running plugin: {}", e); diff --git a/server/algorithms/src/plugin.rs b/server/algorithms/src/plugin.rs index 5cd6592a..58e4df3f 100644 --- a/server/algorithms/src/plugin.rs +++ b/server/algorithms/src/plugin.rs @@ -13,7 +13,7 @@ use rayon::iter::{IntoParallelIterator, ParallelIterator}; pub enum Folder { Routing, Clustering, - // Bootstrap, + Bootstrap, } impl Display for Folder { @@ -21,7 +21,7 @@ impl Display for Folder { match self { Folder::Routing => write!(f, "routing"), Folder::Clustering => write!(f, "clustering"), - // Folder::Bootstrap => write!(f, "bootstrap"), + Folder::Bootstrap => write!(f, "bootstrap"), } } } @@ -37,6 +37,21 @@ pub struct Plugin { pub type JoinFunction = fn(&Plugin, Vec) -> SingleVec; +trait ParseCoord { + fn parse_next_coord(&mut self) -> Option; +} + +impl ParseCoord for std::str::Split<'_, &str> { + fn parse_next_coord(&mut self) -> Option { + if let Some(coord) = self.next() { + if let Ok(coord) = coord.parse::() { + return Some(coord); + } + } + None + } +} + impl Plugin { pub fn new( plugin: &str, @@ -89,18 +104,22 @@ impl Plugin { }) } - pub fn run(&self, points: &SingleVec, joiner: Option) -> Result + pub fn run_multi( + &self, + points: &SingleVec, + joiner: Option, + ) -> Result where T: Fn(&Self, Vec) -> SingleVec, { let handlers = if self.split_level == 0 { - vec![self.spawn(&points)?] + vec![self.run(utils::stringify_points(&points))?] } else { create_cell_map(&points, self.split_level) .into_values() .collect::>() .into_par_iter() - .filter_map(|x| self.spawn(&x).ok()) + .filter_map(|x| self.run(utils::stringify_points(&x)).ok()) .collect() }; if let Some(joiner) = joiner { @@ -110,11 +129,10 @@ impl Plugin { } } - fn spawn(&self, points: &SingleVec) -> Result { + pub fn run(&self, input: String) -> Result { log::info!("spawning {} child process", self.plugin); let time = Instant::now(); - let stringified_points = utils::stringify_points(&points); let mut child = if self.interpreter.is_empty() { Command::new(&self.plugin_path) @@ -128,7 +146,7 @@ impl Plugin { child.arg(arg); } let mut child = match child - .args(&["--input", &stringified_points]) + .args(&["--input", &input]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() @@ -147,7 +165,7 @@ impl Plugin { } }; - match stdin.write_all(stringified_points.as_bytes()) { + match stdin.write_all(input.as_bytes()) { Ok(_) => match stdin.flush() { Ok(_) => {} Err(err) => { @@ -164,10 +182,22 @@ impl Plugin { Err(err) => return Err(err), }; let output = String::from_utf8_lossy(&output.stdout); + // let mut output_indexes = output + // .split(",") + // .filter_map(|s| s.trim().parse::().ok()) + // .collect::>(); let mut output_indexes = output - .split(",") - .filter_map(|s| s.trim().parse::().ok()) - .collect::>(); + .split_ascii_whitespace() + .filter_map(|s| { + let mut iter: std::str::Split<'_, &str> = s.trim().split(","); + let lat = iter.parse_next_coord(); + let lng = iter.parse_next_coord(); + if lat.is_none() || lng.is_none() { + return None; + } + Some([lat.unwrap(), lng.unwrap()]) + }) + .collect::(); if let Some(first) = output_indexes.first() { if let Some(last) = output_indexes.last() { if first == last { @@ -189,7 +219,8 @@ impl Plugin { self.plugin, time.elapsed().as_secs_f32() ); - Ok(output_indexes.into_iter().map(|i| points[i]).collect()) + // Ok(output_indexes.into_iter().map(|i| points[i]).collect()) + Ok(output_indexes) } } } diff --git a/server/algorithms/src/routing/join.rs b/server/algorithms/src/routing/join.rs index 24b7d4d1..70c3f959 100644 --- a/server/algorithms/src/routing/join.rs +++ b/server/algorithms/src/routing/join.rs @@ -24,7 +24,7 @@ pub fn join(plugin: &Plugin, input: Vec) -> SingleVec { point_map.insert(get_cell_id(center), points.clone()); } let clusters: Vec = plugin - .run::(¢roids, None) + .run_multi::(¢roids, None) .unwrap_or(vec![]) .into_iter() .filter_map(|c| { diff --git a/server/algorithms/src/routing/mod.rs b/server/algorithms/src/routing/mod.rs index 48577f94..67cb04cf 100644 --- a/server/algorithms/src/routing/mod.rs +++ b/server/algorithms/src/routing/mod.rs @@ -33,7 +33,7 @@ pub fn main( SortBy::Custom(plugin) => { let clusters = clusters.sort_s2(); match Plugin::new(plugin, Folder::Routing, route_split_level, routing_args) { - Ok(plugin_manager) => match plugin_manager.run(&clusters, Some(join::join)) { + Ok(plugin_manager) => match plugin_manager.run_multi(&clusters, Some(join::join)) { Ok(sorted_clusters) => sorted_clusters, Err(e) => { log::error!("Error while running plugin: {}", e); diff --git a/server/api/src/private/misc.rs b/server/api/src/private/misc.rs index 2281a694..e794eaf5 100644 --- a/server/api/src/private/misc.rs +++ b/server/api/src/private/misc.rs @@ -8,7 +8,7 @@ use crate::{ use actix_session::Session; use actix_web::http::header; -use algorithms::{clustering, routing}; +use algorithms::{bootstrap, clustering, routing}; use geojson::Value; use model::{api::args::Auth, KojiDb}; use serde_json::json; @@ -28,6 +28,7 @@ async fn config(conn: web::Data, session: Session) -> Result, session: Session) -> Result, pub clustering_plugins: Vec, + pub bootstrap_plugins: Vec, } #[derive(Debug, Serialize, Clone)] diff --git a/server/model/src/api/args.rs b/server/model/src/api/args.rs index f17ca392..61d04339 100644 --- a/server/model/src/api/args.rs +++ b/server/model/src/api/args.rs @@ -1,4 +1,4 @@ -use super::{cluster_mode::ClusterMode, sort_by::SortBy, *}; +use super::{calc_mode::CalculationMode, cluster_mode::ClusterMode, sort_by::SortBy, *}; use crate::{ api::{collection::Default, text::TextHelpers}, @@ -164,12 +164,6 @@ pub enum SpawnpointTth { Unknown, } -#[derive(Debug, Deserialize, Clone)] -pub enum CalculationMode { - Radius, - S2, -} - #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(untagged)] pub enum DataPointsArg { @@ -207,13 +201,17 @@ pub struct Args { /// /// Default: `false` pub benchmark_mode: Option, + /// Args to be applied to a custom bootstrapping plugin + /// + /// Default: `''` + pub bootstrap_args: Option, /// Bootstrap mode selection /// /// Accepts [BootStrapMode] /// /// Default: `0` pub calculation_mode: Option, - /// Args to be applied to a custom routing plugin + /// Args to be applied to a custom clustering plugin /// /// Default: `''` pub clustering_args: Option, @@ -387,6 +385,7 @@ pub struct ArgsUnwrapped { pub route_split_level: u64, pub routing_args: String, pub clustering_args: String, + pub bootstrap_args: String, } fn validate_s2_cell(value_to_check: Option, label: &str) -> u64 { @@ -457,6 +456,7 @@ impl Args { route_split_level, routing_args, clustering_args, + bootstrap_args, } = self; let enum_type = get_enum_by_geometry_string(geometry_type); let (area, default_return_type) = if let Some(area) = area { @@ -532,6 +532,7 @@ impl Args { let route_split_level = validate_s2_cell(route_split_level, "route_split_level"); let routing_args = routing_args.unwrap_or("".to_string()); let clustering_args = clustering_args.unwrap_or("".to_string()); + let bootstrap_args = bootstrap_args.unwrap_or("".to_string()); if route_chunk_size.is_some() { log::warn!("route_chunk_size is now deprecated, please use route_split_level") } @@ -569,6 +570,7 @@ impl Args { route_split_level, routing_args, clustering_args, + bootstrap_args, } } } diff --git a/server/model/src/api/calc_mode.rs b/server/model/src/api/calc_mode.rs new file mode 100644 index 00000000..ad27a3d8 --- /dev/null +++ b/server/model/src/api/calc_mode.rs @@ -0,0 +1,23 @@ +use super::*; + +#[derive(Debug, Clone)] +pub enum CalculationMode { + Radius, + S2, + Custom(String), +} + +impl<'de> Deserialize<'de> for CalculationMode { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s: String = Deserialize::deserialize(deserializer)?; + + match s.to_lowercase().as_str() { + "radius" => Ok(CalculationMode::Radius), + "s2" => Ok(CalculationMode::S2), + _ => Ok(CalculationMode::Custom(s)), + } + } +} diff --git a/server/model/src/api/mod.rs b/server/model/src/api/mod.rs index 46723aca..cc1fca8b 100644 --- a/server/model/src/api/mod.rs +++ b/server/model/src/api/mod.rs @@ -8,6 +8,7 @@ use geojson::{Bbox, Geometry, Value}; use sea_orm::FromQueryResult; pub mod args; +pub mod calc_mode; pub mod cluster_mode; pub mod collection; pub mod feature; From 9abe95383cea6a11e24903bbea246822b465b6ce Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 8 Dec 2023 18:37:10 -0500 Subject: [PATCH 08/24] fix: cleanup --- .vscode/settings.json | 80 +------------------------------------------ or-tools/tsp/tsp.cc | 19 ++-------- 2 files changed, 3 insertions(+), 96 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index de3d9b04..dafb5b05 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,85 +10,7 @@ "editor.formatOnSave": true, "rust-analyzer.showUnlinkedFileNotification": false, "files.associations": { - "vector": "cpp", - "__bit_reference": "cpp", - "__bits": "cpp", - "__config": "cpp", - "__debug": "cpp", - "__errc": "cpp", - "__hash_table": "cpp", - "__locale": "cpp", - "__mutex_base": "cpp", - "__node_handle": "cpp", - "__nullptr": "cpp", - "__split_buffer": "cpp", - "__string": "cpp", - "__threading_support": "cpp", - "__tree": "cpp", - "__tuple": "cpp", - "any": "cpp", - "array": "cpp", - "atomic": "cpp", - "bit": "cpp", - "bitset": "cpp", - "cctype": "cpp", - "chrono": "cpp", - "cinttypes": "cpp", - "clocale": "cpp", - "cmath": "cpp", - "compare": "cpp", - "complex": "cpp", - "concepts": "cpp", - "condition_variable": "cpp", - "csignal": "cpp", - "cstdarg": "cpp", - "cstddef": "cpp", - "cstdint": "cpp", - "cstdio": "cpp", - "cstdlib": "cpp", - "cstring": "cpp", - "ctime": "cpp", - "cwchar": "cpp", - "cwctype": "cpp", - "deque": "cpp", - "exception": "cpp", - "forward_list": "cpp", - "fstream": "cpp", - "future": "cpp", - "initializer_list": "cpp", - "iomanip": "cpp", - "ios": "cpp", - "iosfwd": "cpp", - "iostream": "cpp", - "istream": "cpp", - "limits": "cpp", - "list": "cpp", - "locale": "cpp", - "map": "cpp", - "memory": "cpp", - "mutex": "cpp", - "new": "cpp", - "numeric": "cpp", - "optional": "cpp", - "ostream": "cpp", - "queue": "cpp", - "random": "cpp", - "ratio": "cpp", - "set": "cpp", - "sstream": "cpp", - "stack": "cpp", - "stdexcept": "cpp", - "streambuf": "cpp", - "string": "cpp", - "string_view": "cpp", - "system_error": "cpp", - "tuple": "cpp", - "type_traits": "cpp", - "typeinfo": "cpp", - "unordered_map": "cpp", - "unordered_set": "cpp", - "variant": "cpp", - "algorithm": "cpp" + "vector": "cpp" }, "cmake.configureOnOpen": false } \ No newline at end of file diff --git a/or-tools/tsp/tsp.cc b/or-tools/tsp/tsp.cc index 6c372151..67f60743 100644 --- a/or-tools/tsp/tsp.cc +++ b/or-tools/tsp/tsp.cc @@ -166,18 +166,6 @@ std::vector split(const std::string &s, char delimiter) return tokens; } -double stodpre(std::string const &str, std::size_t const p) -{ - std::stringstream sstrm; - sstrm << std::setprecision(p) << std::fixed << str << std::endl; - - LOG(INFO) << "Stream: " << sstrm.str(); - double d; - sstrm >> d; - - return d; -} - int main(int argc, char *argv[]) { std::map args; @@ -206,12 +194,9 @@ int main(int argc, char *argv[]) } } } - else + else if (i + 1 < argc) { - if (i + 1 < argc) - { - args[key] = argv[++i]; - } + args[key] = argv[++i]; } } } From 5107523d8dbaefec7a4d0fbd08e150b53cb972d8 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 14 Dec 2023 14:17:40 -0500 Subject: [PATCH 09/24] refactor: make the args more robust --- .../src/components/drawer/inputs/NumInput.tsx | 1 + server/algorithms/src/plugin.rs | 128 +++++++++++------- server/algorithms/src/routing/join.rs | 3 + 3 files changed, 84 insertions(+), 48 deletions(-) diff --git a/client/src/components/drawer/inputs/NumInput.tsx b/client/src/components/drawer/inputs/NumInput.tsx index dca1f650..1344a44e 100644 --- a/client/src/components/drawer/inputs/NumInput.tsx +++ b/client/src/components/drawer/inputs/NumInput.tsx @@ -47,6 +47,7 @@ export default function UserTextInput< } label={isNumber ? undefined : finalLabel} sx={{ width: isNumber ? '35%' : '100%' }} + multiline={!isNumber} inputProps={{ min: min || 0, max: max || 9999 }} InputProps={{ endAdornment }} disabled={disabled} diff --git a/server/algorithms/src/plugin.rs b/server/algorithms/src/plugin.rs index 58e4df3f..906bdefb 100644 --- a/server/algorithms/src/plugin.rs +++ b/server/algorithms/src/plugin.rs @@ -7,7 +7,7 @@ use std::time::Instant; use crate::s2::create_cell_map; use crate::utils; use model::api::single_vec::SingleVec; -use rayon::iter::{IntoParallelIterator, ParallelIterator}; +use rayon::iter::{Either, IntoParallelIterator, ParallelIterator}; #[derive(Debug)] pub enum Folder { @@ -59,10 +59,57 @@ impl Plugin { route_split_level: u64, routing_args: &str, ) -> std::io::Result { - let path = format!("algorithms/src/{folder}/plugins/{plugin}"); - let path = Path::new(path.as_str()); - let plugin_path = if path.exists() { - path.display().to_string() + let mut plugin_path = format!("algorithms/src/{folder}/plugins/{plugin}"); + let mut interpreter = match plugin.split(".").last() { + Some("py") => "python3", + Some("js") => "node", + Some("sh") => "bash", + Some("ts") => "ts-node", + val => { + if plugin == val.unwrap_or("") { + &plugin_path + } else { + "" + } + } + } + .to_string(); + let args = routing_args + .split_whitespace() + .skip_while(|arg| !arg.starts_with("--")) + .map(|arg| arg.to_string()) + .collect::>(); + + for (index, pre_arg) in routing_args + .split_whitespace() + .take_while(|arg| !arg.starts_with("--")) + .enumerate() + { + log::info!("[PLUGIN PARSER] {index} | pre_arg: {}", pre_arg); + if index == 0 { + interpreter = pre_arg.to_string(); + } else if index == 1 { + plugin_path = format!("algorithms/src/{folder}/plugins/{pre_arg}"); + } else { + log::warn!("Unrecognized argument: {pre_arg} for plugin: {plugin}") + } + } + + if interpreter.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Unrecognized plugin, please create a PR to add support for it", + )); + }; + let path = Path::new(&plugin_path); + if path.is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("{plugin} is a directory, not a file, something may not be right with the provided args"), + )); + } else if path.exists() { + plugin_path = path.display().to_string(); + log::info!("{interpreter} {plugin_path} {}", args.join(" ")); } else { return Err(std::io::Error::new( std::io::ErrorKind::NotFound, @@ -75,32 +122,14 @@ impl Plugin { } ), )); - }; + } - let interpreter = match plugin.split(".").last() { - Some("py") => "python3", - Some("js") => "node", - Some("ts") => "ts-node", - val => { - if plugin == val.unwrap_or("") { - "" - } else { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "Unrecognized plugin, please create a PR to add support for it", - )); - } - } - }; Ok(Plugin { plugin: plugin.to_string(), plugin_path, - interpreter: interpreter.to_string(), + interpreter, split_level: route_split_level, - args: routing_args - .split_ascii_whitespace() - .map(|s| s.to_string()) - .collect::>(), + args, }) } @@ -134,18 +163,12 @@ impl Plugin { let time = Instant::now(); - let mut child = if self.interpreter.is_empty() { - Command::new(&self.plugin_path) - } else { - Command::new(&self.interpreter) - }; - if !self.interpreter.is_empty() { + let mut child = Command::new(&self.interpreter); + if self.plugin_path != self.interpreter { child.arg(&self.plugin_path); - } - for arg in self.args.iter() { - child.arg(arg); - } + }; let mut child = match child + .args(self.args.iter()) .args(&["--input", &input]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -186,30 +209,39 @@ impl Plugin { // .split(",") // .filter_map(|s| s.trim().parse::().ok()) // .collect::>(); - let mut output_indexes = output + let (invalid, mut output_result): (Vec<&str>, SingleVec) = output .split_ascii_whitespace() - .filter_map(|s| { + .into_iter() + .collect::>() + .into_par_iter() + .partition_map(|s| { let mut iter: std::str::Split<'_, &str> = s.trim().split(","); let lat = iter.parse_next_coord(); let lng = iter.parse_next_coord(); if lat.is_none() || lng.is_none() { - return None; + Either::Left(s) + } else { + Either::Right([lat.unwrap(), lng.unwrap()]) } - Some([lat.unwrap(), lng.unwrap()]) - }) - .collect::(); - if let Some(first) = output_indexes.first() { - if let Some(last) = output_indexes.last() { + }); + if let Some(first) = output_result.first() { + if let Some(last) = output_result.last() { if first == last { - output_indexes.pop(); + output_result.pop(); } } } - if output_indexes.is_empty() { + if !invalid.is_empty() { + log::warn!( + "Some invalid results were returned from the plugin: `{}`", + invalid.join(", ") + ); + } + if output_result.is_empty() { Err(std::io::Error::new( std::io::ErrorKind::NotFound, format!( - "no valid output from child process \n{}\noutput should return comma separated indexes of the input clusters in the order they should be routed", + "no valid output from child process \n{}\noutput should return points in the following format: `lat,lng lat,lng`", output ), )) @@ -220,7 +252,7 @@ impl Plugin { time.elapsed().as_secs_f32() ); // Ok(output_indexes.into_iter().map(|i| points[i]).collect()) - Ok(output_indexes) + Ok(output_result) } } } diff --git a/server/algorithms/src/routing/join.rs b/server/algorithms/src/routing/join.rs index 70c3f959..c610acc3 100644 --- a/server/algorithms/src/routing/join.rs +++ b/server/algorithms/src/routing/join.rs @@ -8,6 +8,9 @@ use std::collections::HashMap; use std::time::Instant; pub fn join(plugin: &Plugin, input: Vec) -> SingleVec { + if plugin.split_level == 0 { + return input.into_iter().flatten().collect(); + } let time = Instant::now(); let mut point_map = HashMap::::new(); From 31246e48f7abdd2dfc49819faa06019b294d75cf Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 14 Dec 2023 15:18:49 -0500 Subject: [PATCH 10/24] refactor: more consistent naming --- client/src/components/drawer/Routing.tsx | 2 +- client/src/hooks/usePersist.ts | 4 ++-- client/src/services/fetches.ts | 4 ++-- server/algorithms/src/bootstrap/mod.rs | 4 ++-- server/algorithms/src/plugin.rs | 12 +++++++++--- server/api/src/public/v1/calculate.rs | 4 ++-- server/model/src/api/args.rs | 10 +++++----- 7 files changed, 23 insertions(+), 17 deletions(-) diff --git a/client/src/components/drawer/Routing.tsx b/client/src/components/drawer/Routing.tsx index 2697b39c..8a93fe80 100644 --- a/client/src/components/drawer/Routing.tsx +++ b/client/src/components/drawer/Routing.tsx @@ -96,7 +96,7 @@ export default function RoutingTab() { !CALC_MODE.some((x) => x === calculation_mode) && mode === 'bootstrap' } > - + diff --git a/client/src/hooks/usePersist.ts b/client/src/hooks/usePersist.ts index e38ae537..3ab10a93 100644 --- a/client/src/hooks/usePersist.ts +++ b/client/src/hooks/usePersist.ts @@ -74,7 +74,7 @@ export interface UsePersist { max_clusters: number routing_args: string clustering_args: string - bootstrap_args: string + bootstrapping_args: string // generations: number | '' // routing_time: number | '' // devices: number | '' @@ -158,7 +158,7 @@ export const usePersist = create( spawnpointMaxAreaAutoCalc: 100, routing_args: '', clustering_args: '', - bootstrap_args: '', + bootstrapping_args: '', setStore: (key, value) => set({ [key]: value }), }), { diff --git a/client/src/services/fetches.ts b/client/src/services/fetches.ts index 1f0471fa..b4093f80 100644 --- a/client/src/services/fetches.ts +++ b/client/src/services/fetches.ts @@ -135,7 +135,7 @@ export async function clusteringRouting({ max_clusters, routing_args, clustering_args, - bootstrap_args, + bootstrapping_args, } = usePersist.getState() const { geojson, setStatic, bounds } = useStatic.getState() const { add, activeRoute } = useShapes.getState().setters @@ -250,7 +250,7 @@ export async function clusteringRouting({ s2_size, routing_args, clustering_args, - bootstrap_args, + bootstrapping_args, }), }, ) diff --git a/server/algorithms/src/bootstrap/mod.rs b/server/algorithms/src/bootstrap/mod.rs index acac3e4f..86f4b544 100644 --- a/server/algorithms/src/bootstrap/mod.rs +++ b/server/algorithms/src/bootstrap/mod.rs @@ -22,7 +22,7 @@ pub fn main( route_split_level: u64, stats: &mut Stats, routing_args: &str, - bootstrap_args: &str, + bootstrapping_rags: &str, ) -> Vec { let mut features = vec![]; @@ -43,7 +43,7 @@ pub fn main( features.push(new_s2.feature()); } CalculationMode::Custom(plugin) => { - match Plugin::new(plugin, Folder::Bootstrap, 0, bootstrap_args) { + match Plugin::new(plugin, Folder::Bootstrap, 0, bootstrapping_rags) { Ok(plugin_manager) => { let time = Instant::now(); match plugin_manager.run(feature.to_string()) { diff --git a/server/algorithms/src/plugin.rs b/server/algorithms/src/plugin.rs index 906bdefb..257f160f 100644 --- a/server/algorithms/src/plugin.rs +++ b/server/algorithms/src/plugin.rs @@ -57,9 +57,15 @@ impl Plugin { plugin: &str, folder: Folder, route_split_level: u64, - routing_args: &str, + input_args: &str, ) -> std::io::Result { let mut plugin_path = format!("algorithms/src/{folder}/plugins/{plugin}"); + if !Path::new(&plugin_path).exists() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("plugin {plugin} does not exist"), + )); + } let mut interpreter = match plugin.split(".").last() { Some("py") => "python3", Some("js") => "node", @@ -74,13 +80,13 @@ impl Plugin { } } .to_string(); - let args = routing_args + let args = input_args .split_whitespace() .skip_while(|arg| !arg.starts_with("--")) .map(|arg| arg.to_string()) .collect::>(); - for (index, pre_arg) in routing_args + for (index, pre_arg) in input_args .split_whitespace() .take_while(|arg| !arg.starts_with("--")) .enumerate() diff --git a/server/api/src/public/v1/calculate.rs b/server/api/src/public/v1/calculate.rs index 0462a089..99a47144 100644 --- a/server/api/src/public/v1/calculate.rs +++ b/server/api/src/public/v1/calculate.rs @@ -37,7 +37,7 @@ async fn bootstrap( sort_by, route_split_level, routing_args, - bootstrap_args, + bootstrapping_args, .. } = payload.into_inner().init(Some("bootstrap")); @@ -63,7 +63,7 @@ async fn bootstrap( route_split_level, &mut stats, &routing_args, - &bootstrap_args, + &bootstrapping_args, ); if parent.is_some() { diff --git a/server/model/src/api/args.rs b/server/model/src/api/args.rs index 61d04339..28f141e8 100644 --- a/server/model/src/api/args.rs +++ b/server/model/src/api/args.rs @@ -204,7 +204,7 @@ pub struct Args { /// Args to be applied to a custom bootstrapping plugin /// /// Default: `''` - pub bootstrap_args: Option, + pub bootstrapping_args: Option, /// Bootstrap mode selection /// /// Accepts [BootStrapMode] @@ -385,7 +385,7 @@ pub struct ArgsUnwrapped { pub route_split_level: u64, pub routing_args: String, pub clustering_args: String, - pub bootstrap_args: String, + pub bootstrapping_args: String, } fn validate_s2_cell(value_to_check: Option, label: &str) -> u64 { @@ -456,7 +456,7 @@ impl Args { route_split_level, routing_args, clustering_args, - bootstrap_args, + bootstrapping_args, } = self; let enum_type = get_enum_by_geometry_string(geometry_type); let (area, default_return_type) = if let Some(area) = area { @@ -532,7 +532,7 @@ impl Args { let route_split_level = validate_s2_cell(route_split_level, "route_split_level"); let routing_args = routing_args.unwrap_or("".to_string()); let clustering_args = clustering_args.unwrap_or("".to_string()); - let bootstrap_args = bootstrap_args.unwrap_or("".to_string()); + let bootstrapping_args = bootstrapping_args.unwrap_or("".to_string()); if route_chunk_size.is_some() { log::warn!("route_chunk_size is now deprecated, please use route_split_level") } @@ -570,7 +570,7 @@ impl Args { route_split_level, routing_args, clustering_args, - bootstrap_args, + bootstrapping_args, } } } From 960f3e3ae09d8ae1c7364ca396f72eb691995cfa Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 14 Dec 2023 16:15:10 -0500 Subject: [PATCH 11/24] docs: plugins --- docs/pages/_meta.json | 1 + docs/pages/plugins.mdx | 220 +++++++++++++++++++ docs/public/images/plugins/bootstrapping.png | Bin 0 -> 42532 bytes docs/public/images/plugins/clustering.png | Bin 0 -> 47733 bytes docs/public/images/plugins/routing.png | Bin 0 -> 35185 bytes server/algorithms/src/plugin.rs | 2 +- server/model/src/api/args.rs | 11 +- 7 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 docs/pages/plugins.mdx create mode 100644 docs/public/images/plugins/bootstrapping.png create mode 100644 docs/public/images/plugins/clustering.png create mode 100644 docs/public/images/plugins/routing.png diff --git a/docs/pages/_meta.json b/docs/pages/_meta.json index 9b95a7e5..912d6f05 100644 --- a/docs/pages/_meta.json +++ b/docs/pages/_meta.json @@ -3,6 +3,7 @@ "setup": "Setup", "client": "Client", "api-reference": "API Reference", + "plugins": "Plugins", "algorithms": "Algorithms", "integrations": "Integrations", "development": "Development" diff --git a/docs/pages/plugins.mdx b/docs/pages/plugins.mdx new file mode 100644 index 00000000..e1d45068 --- /dev/null +++ b/docs/pages/plugins.mdx @@ -0,0 +1,220 @@ +import Image from 'next/image' + +# Plugins + +Kōji has an integrated plugin system to extend three of its core algorithms: + +- Clustering +- Routing +- Bootstrapping + +## How to Use + +Plugins are loaded from the respective `plugins` directory found in each of the three algorithm directories. A plugin can be a single file or a directory containing any number of files or directories. You can pass in your own arguments via the Kōji client or the API. Each arg should be separated by a space, keys starting with `--` and the respective values following the keys. For example, `--arg1 value1 --arg2 value2`. An `--input` arg is appended by Kōji, the values of which are described below in each respective section. + +### Single File + +A plugin file can either be executable or a script that is run by an interpreter. By default, Kōji supports the following plugins without any additional args: + +- `.sh` will be executed with Bash +- `.js` will be executed with Node +- `.ts` will be executed with TS-Node +- `.py` will be executed with Python +- Extension-less, executable binaries + +If you would like to use a different interpreter for a plugin you can add it to the respective args. For example, if you would like to use Bun, you can prefix your input arguments with `bun file.ts`. + +### Directory + +A directory can also be used a plugin. The directory name will be used as the plugin name and you must add the relative path from the plugin folder to the plugin entry point to your input arguments. For example, `bun my_plugin/index.ts`. + +### Example Folder Structure & Usage + +```bash +. +├── client +├── docs +├── or-tools +└── server + ├── algorithms + │ ├── clustering + │ │ └── src + │ │ └── plugins + │ │ ├── plugin_1 + │ │ │ │ # clustering_args = `python3 plugin_1/clustering_plugin.py` + │ │ │ └── clustering_plugin.py + │ │ │ # clustering_args = `` - executed automatically with Node by Kōji + │ │ ├── plugin_2.js + │ │ │ # clustering_args = `` - executed automatically Kōji + │ │ └── plugin_3_binary + │ ├── routing + │ │ └── src + │ │ └── plugins + │ │ ├── plugin_1 + │ │ │ │ # routing_args = `bash plugin_1/my_custom_plugin.sh` + │ │ │ └── my_custom_plugin.sh + │ │ │ # routing_args = `bun plugin_2.ts` + │ │ ├── plugin_2.ts + │ │ │ # routing_args = `` - executed automatically by Kōji + │ │ └── plugin_3_binary + │ └── bootstrapping + │ └── src + │ └── plugins + │ ├── plugin_1 + │ │ │ # bootstrapping_args = `node plugin_1/bootstrapping_plugin.js` + │ │ └── bootstrapping_plugin.js + │ │ # bootstrapping_args = `` - executed automatically by Kōji + │ ├── plugin_2.sh + │ │ # bootstrapping_args = `` - executed automatically by Kōji + │ └── plugin_3_binary + └── src +``` + +### Parsing Examples + +Below are some examples demonstrating how the input args are parsed and passed along to your plugin. These are category agnostic. The `Entry` is the file in which the plugin must be executed from. This will either be an individual file that's directly placed in the plugin folder or a full path that includes a directory. The `Args` value is the value that you pass in via the Kōji client or API. + +#### Example 1 + +- Entry: `cluster.py` +- Args: `--foo 1 --bar 2` +- Radius: 70 +- Min Points: 3 +- Max Clusters: 500 + +Result: `python cluster.py --foo 1 --bar 2 --radius 70 --min_points 3 --max_clusters 500 --input 40.780374,-73.969161 40.252042,-73.882841 40.256022,-74.105120` + +#### Example 2 + +- Entry: `my_plugin/routing.js` +- Args: `node my_plugin/routing.js --baz 10 --qux hello!` + +Result: `node my_plugin/routing.js --baz 10 --qux hello! --input 40.780374,-73.969161 40.252042,-73.882841 40.256022,-74.105120` + +#### Example 3 + +- Entry: `test.ts` +- Args: `bun` + +Result: `bun test.ts --input 40.780374,-73.969161 40.252042,-73.882841 40.256022,-74.105120` + +The main takeaway is that the first and second arguments are optional. Once Kōji finds the first argument that is prefixed by `--`, it now assumes that the rest of the arguments are meant to be passed to the plugin. If your plugin is only a single file, you can omit the interpreter and file path, or you can just omit the file path, however you can not omit the interpreter and try to use a custom file path. + +## Clustering + +### Input Value + +The input value for a clustering plugin is a stringified list of points of `n` length: `lat,lng lat,lng lat,lng ...`. These are the points that are to be clustered, e.g. spawnpoints or forts. + +### Automatically Appended Args: + +- radius +- min_points +- max_clusters +- input + +### Example Usage + +- Plugin: `custom.py` +- The plugin accepts the following custom args: + - foo + - bar + +#### API Usage + +```json +{ + ... // other args + "cluster_mode": "custom.py", + "clustering_args": "--foo 1 --bar 123" + ... +} +``` + +#### Client Usage + +Set your options like so in the Kōji client drawer: + +Clustering Plugin Example + +## Routing + +### Input Value + +The input value for the routing plugin is a stringified list of points of `n` length: `lat,lng lat,lng lat,lng ...`. These are the cluster values. + +### Automatically Appended Args: + +- input + +### Example Usage + +- Plugin: `routing.js` +- The plugin accepts the following custom args: + - baz + - qux + +#### API Usage + +```json +{ + ... // other args + "sort_by": "routing.js", + "routing_args": "--baz 10 --qux hello!" + ... +} +``` + +#### Client Usage + +Set your options like so in the Kōji client drawer: + +Routing Plugin Example + +## Bootstrapping + +### Input Value + +The input value for the bootstrapping plugin is a GeoJSON Feature of either a Polygon or MultiPolygon type. This is the area that will be used to constrain the points that the bootstrap algorithm generates. + +### Automatically Appended Args: + +- radius +- input + +### Example Usage + +- Plugin: `abc/some_plugin.py` +- The plugin entry point is located in a directory so we must specify the interpreter and path. + +#### API Usage + +```json +{ + ... // other args + "calculation_mode": "abc", + "bootstrapping_args": "python3 abc/some_plugin.py" // can be omitted but included here for demonstration purposes + ... +} +``` + +#### Client Usage + +Set your options like so in the Kōji client drawer: + +Bootstrapping Plugin Example diff --git a/docs/public/images/plugins/bootstrapping.png b/docs/public/images/plugins/bootstrapping.png new file mode 100644 index 0000000000000000000000000000000000000000..70dbebe7ebc55684a8a41fc8d25c578e046f0906 GIT binary patch literal 42532 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 AB>(^b literal 0 HcmV?d00001 diff --git a/docs/public/images/plugins/clustering.png b/docs/public/images/plugins/clustering.png new file mode 100644 index 0000000000000000000000000000000000000000..df17340651166e21abe488925417a7f26a10cfbd GIT binary patch literal 47733 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 z%0A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 literal 0 HcmV?d00001 diff --git a/docs/public/images/plugins/routing.png b/docs/public/images/plugins/routing.png new file mode 100644 index 0000000000000000000000000000000000000000..c5c4c62bf151594991a0816accc34c133fe7ef21 GIT binary patch literal 35185 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 literal 0 HcmV?d00001 diff --git a/server/algorithms/src/plugin.rs b/server/algorithms/src/plugin.rs index 257f160f..f4e5e4ce 100644 --- a/server/algorithms/src/plugin.rs +++ b/server/algorithms/src/plugin.rs @@ -67,7 +67,7 @@ impl Plugin { )); } let mut interpreter = match plugin.split(".").last() { - Some("py") => "python3", + Some("py") => "python", Some("js") => "node", Some("sh") => "bash", Some("ts") => "ts-node", diff --git a/server/model/src/api/args.rs b/server/model/src/api/args.rs index 28f141e8..dfe31cb7 100644 --- a/server/model/src/api/args.rs +++ b/server/model/src/api/args.rs @@ -531,8 +531,15 @@ impl Args { let mode = get_enum(mode); let route_split_level = validate_s2_cell(route_split_level, "route_split_level"); let routing_args = routing_args.unwrap_or("".to_string()); - let clustering_args = clustering_args.unwrap_or("".to_string()); - let bootstrapping_args = bootstrapping_args.unwrap_or("".to_string()); + + let mut clustering_args = clustering_args.unwrap_or("".to_string()); + clustering_args += &format!(" --radius {}", radius); + clustering_args += &format!(" --min_points {}", min_points); + clustering_args += &format!(" --max_clusters {}", max_clusters); + + let mut bootstrapping_args = bootstrapping_args.unwrap_or("".to_string()); + bootstrapping_args += &format!(" --radius {}", radius); + if route_chunk_size.is_some() { log::warn!("route_chunk_size is now deprecated, please use route_split_level") } From f4014cf1b0b35eaf10c79ab487f5eba6a0ffa526 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Thu, 14 Dec 2023 16:28:28 -0500 Subject: [PATCH 12/24] docs: add returning results & example --- docs/pages/plugins.mdx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/pages/plugins.mdx b/docs/pages/plugins.mdx index e1d45068..bbe98ef5 100644 --- a/docs/pages/plugins.mdx +++ b/docs/pages/plugins.mdx @@ -218,3 +218,11 @@ Set your options like so in the Kōji client drawer: width={300} height={300} /> + +## Returning Results to Kōji + +The results of your plugin must be returned to Kōji via stdout. The results must be a stringified list of points of `n` length: `lat,lng lat,lng lat,lng ...`. In Python for example, this is as simple as printing the results. While Kōji attempts to filter out any unnecessary or invalid text that was logged, it's best not to log anything other than the final results. + +## Plugin Example + +You can already view a live plugin example as the OR-Tools integration (TSP) has been reworker to be used with this plugin system. You can view the source code [here](https://github.com/TurtIeSocks/Koji/blob/main/or-tools/tsp/tsp.cc). From e510fe68c60c140708f74165dd8974095024390c Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Wed, 27 Dec 2023 15:36:52 -0500 Subject: [PATCH 13/24] Update Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 6517e49f..b3f5bfea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,5 +28,5 @@ RUN cat ortools.tar.gz | tar -xzf - && \ cp /tsp/tsp.cc ./examples/koji/koji.cc && \ cp /tsp/CMakeLists.txt ./examples/koji/CMakeLists.txt && \ make build SOURCE=examples/koji/koji.cc && \ - mv ./examples/koji/build/bin/koji /algorithms/src/routing/tsp + mv ./examples/koji/build/bin/koji /algorithms/src/routing/plugins/tsp CMD koji From f46c1581d6d03cb0ec27e0e5e756f9b2620c1f6c Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Wed, 27 Dec 2023 15:45:07 -0500 Subject: [PATCH 14/24] Update Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b3f5bfea..9a471162 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,7 @@ COPY --from=client /app/dist ./dist COPY --from=server /usr/local/cargo/bin/koji /usr/local/bin/koji RUN apt install curl -y RUN apt install -y build-essential cmake lsb-release -RUN mkdir -p /algorithms/src/routing +RUN mkdir -p /algorithms/src/routing/plugins COPY ./or-tools . RUN curl -L https://github.com/google/or-tools/releases/download/v9.5/or-tools_amd64_debian-11_cpp_v9.5.2237.tar.gz -o ortools.tar.gz RUN cat ortools.tar.gz | tar -xzf - && \ From ab601f4dea3e35dfc6da61bed618f78e8a031edd Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 29 Dec 2023 00:32:53 -0500 Subject: [PATCH 15/24] fix: streaming plugin stdout to koji --- or-tools/tsp/tsp.cc | 3 +- server/algorithms/src/plugin.rs | 91 +++++++++++++++------------------ 2 files changed, 43 insertions(+), 51 deletions(-) diff --git a/or-tools/tsp/tsp.cc b/or-tools/tsp/tsp.cc index 67f60743..ea8b643b 100644 --- a/or-tools/tsp/tsp.cc +++ b/or-tools/tsp/tsp.cc @@ -205,7 +205,8 @@ int main(int argc, char *argv[]) for (auto point : routes) { - std::cout << stringPoints[point] << " "; + std::cout << stringPoints[point] << std::endl + << std::flush; } return EXIT_SUCCESS; diff --git a/server/algorithms/src/plugin.rs b/server/algorithms/src/plugin.rs index f4e5e4ce..dd7df4a7 100644 --- a/server/algorithms/src/plugin.rs +++ b/server/algorithms/src/plugin.rs @@ -1,5 +1,5 @@ use std::fmt::Display; -use std::io::Write; +use std::io::{self, BufRead, BufReader}; use std::path::Path; use std::process::{Command, Stdio}; use std::time::Instant; @@ -7,7 +7,7 @@ use std::time::Instant; use crate::s2::create_cell_map; use crate::utils; use model::api::single_vec::SingleVec; -use rayon::iter::{Either, IntoParallelIterator, ParallelIterator}; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; #[derive(Debug)] pub enum Folder { @@ -184,71 +184,62 @@ impl Plugin { Err(err) => return Err(err), }; - let mut stdin = match child.stdin.take() { - Some(stdin) => stdin, - None => { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "failed to open stdin", - )); + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Could not capture stdout"))?; + + let mut results = vec![]; + let mut invalid = vec![]; + let reader = BufReader::new(stdout); + for line in reader.lines() { + match line { + Ok(line) => { + let mut iter: std::str::Split<'_, &str> = line.trim().split(","); + let lat = iter.parse_next_coord(); + let lng = iter.parse_next_coord(); + if lat.is_none() || lng.is_none() { + invalid.push(line) + } else { + results.push([lat.unwrap(), lng.unwrap()]) + } + } + Err(e) => { + log::error!("Error reading line: {}", e); + } } - }; + } - match stdin.write_all(input.as_bytes()) { - Ok(_) => match stdin.flush() { - Ok(_) => {} - Err(err) => { - log::error!("failed to flush stdin: {}", err); - } - }, - Err(err) => { - log::error!("failed to write to stdin: {}", err) + match child.wait()? { + status if status.success() => {} + status => { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("child process exited with status: {}", status), + )) } - }; + } - let output = match child.wait_with_output() { - Ok(result) => result, - Err(err) => return Err(err), - }; - let output = String::from_utf8_lossy(&output.stdout); - // let mut output_indexes = output - // .split(",") - // .filter_map(|s| s.trim().parse::().ok()) - // .collect::>(); - let (invalid, mut output_result): (Vec<&str>, SingleVec) = output - .split_ascii_whitespace() - .into_iter() - .collect::>() - .into_par_iter() - .partition_map(|s| { - let mut iter: std::str::Split<'_, &str> = s.trim().split(","); - let lat = iter.parse_next_coord(); - let lng = iter.parse_next_coord(); - if lat.is_none() || lng.is_none() { - Either::Left(s) - } else { - Either::Right([lat.unwrap(), lng.unwrap()]) - } - }); - if let Some(first) = output_result.first() { - if let Some(last) = output_result.last() { + if let Some(first) = results.first() { + if let Some(last) = results.last() { if first == last { - output_result.pop(); + results.pop(); } } } + if !invalid.is_empty() { log::warn!( "Some invalid results were returned from the plugin: `{}`", invalid.join(", ") ); } - if output_result.is_empty() { + if results.is_empty() { Err(std::io::Error::new( std::io::ErrorKind::NotFound, format!( "no valid output from child process \n{}\noutput should return points in the following format: `lat,lng lat,lng`", - output + invalid.join(", ") ), )) } else { @@ -258,7 +249,7 @@ impl Plugin { time.elapsed().as_secs_f32() ); // Ok(output_indexes.into_iter().map(|i| points[i]).collect()) - Ok(output_result) + Ok(results) } } } From fc3ebca64929016bc2f57a46575a1d689f6604f2 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Fri, 29 Dec 2023 14:31:48 -0500 Subject: [PATCH 16/24] fix: only set mode if it's missing --- server/api/src/public/v1/calculate.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/server/api/src/public/v1/calculate.rs b/server/api/src/public/v1/calculate.rs index 99a47144..e60850d8 100644 --- a/server/api/src/public/v1/calculate.rs +++ b/server/api/src/public/v1/calculate.rs @@ -96,14 +96,16 @@ async fn bootstrap( if !feat.contains_property("__name") && !instance.is_empty() { feat.set_property("__name", instance.clone()); } - feat.set_property( - "__mode", - if conn.scanner_type == ScannerType::Unown { - "circle_pokemon" - } else { - "circle_smart_pokemon" - }, - ); + if !feat.contains_property("__mode") { + feat.set_property( + "__mode", + if conn.scanner_type == ScannerType::Unown { + "circle_pokemon" + } else { + "circle_smart_pokemon" + }, + ); + } if save_to_db { route::Query::upsert_from_geometry(&conn.koji, GeoFormats::Feature(feat.clone())) .await From 2479d8dabbbac29c4ee77131c8500d2e32ce2f11 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Mon, 1 Jan 2024 15:53:10 -0500 Subject: [PATCH 17/24] fix: tsp plugin --- or-tools/tsp/tsp.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/or-tools/tsp/tsp.cc b/or-tools/tsp/tsp.cc index ea8b643b..0914002c 100644 --- a/or-tools/tsp/tsp.cc +++ b/or-tools/tsp/tsp.cc @@ -137,7 +137,7 @@ namespace operations_research searchParameters.set_first_solution_strategy( FirstSolutionStrategy::PATH_CHEAPEST_ARC); - if (locations.size() > 1000) + if (locations.size() > 2000) { searchParameters.set_local_search_metaheuristic( LocalSearchMetaheuristic::GUIDED_LOCAL_SEARCH); From 0b686666fad9e5671c073df2cdb5fde777dd8c75 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Wed, 28 Feb 2024 13:32:10 -0500 Subject: [PATCH 18/24] fix: go back to stdin due to arg length limits --- docs/pages/plugins.mdx | 33 +++++++++++++++------------------ or-tools/tsp/tsp.cc | 31 ++++++++++++++----------------- server/algorithms/src/plugin.rs | 30 ++++++++++++++++++++++++++---- 3 files changed, 55 insertions(+), 39 deletions(-) diff --git a/docs/pages/plugins.mdx b/docs/pages/plugins.mdx index bbe98ef5..967729b3 100644 --- a/docs/pages/plugins.mdx +++ b/docs/pages/plugins.mdx @@ -10,7 +10,7 @@ Kōji has an integrated plugin system to extend three of its core algorithms: ## How to Use -Plugins are loaded from the respective `plugins` directory found in each of the three algorithm directories. A plugin can be a single file or a directory containing any number of files or directories. You can pass in your own arguments via the Kōji client or the API. Each arg should be separated by a space, keys starting with `--` and the respective values following the keys. For example, `--arg1 value1 --arg2 value2`. An `--input` arg is appended by Kōji, the values of which are described below in each respective section. +Plugins are loaded from the respective `plugins` directory found in each of the three algorithm directories. A plugin can be a single file or a directory containing any number of files or directories. You can pass in your own arguments via the Kōji client or the API. Each arg should be separated by a space, keys starting with `--` and the respective values following the keys. For example, `--arg1 value1 --arg2 value2`. Due to limits on the length of an input argument, the coordinates or GeoJSON Feature will be passed via stdin from Kōji to your plugin. ### Single File @@ -18,8 +18,8 @@ A plugin file can either be executable or a script that is run by an interpreter - `.sh` will be executed with Bash - `.js` will be executed with Node -- `.ts` will be executed with TS-Node -- `.py` will be executed with Python +- `.ts` will be executed with [tsx](https://www.npmjs.com/package/tsx) +- `.py` will be executed with Python3 - Extension-less, executable binaries If you would like to use a different interpreter for a plugin you can add it to the respective args. For example, if you would like to use Bun, you can prefix your input arguments with `bun file.ts`. @@ -82,36 +82,38 @@ Below are some examples demonstrating how the input args are parsed and passed a - Min Points: 3 - Max Clusters: 500 -Result: `python cluster.py --foo 1 --bar 2 --radius 70 --min_points 3 --max_clusters 500 --input 40.780374,-73.969161 40.252042,-73.882841 40.256022,-74.105120` +Result: `python3 cluster.py --foo 1 --bar 2 --radius 70 --min_points 3 --max_clusters 500` +Stdin: `40.780374,-73.969161 40.252042,-73.882841 40.256022,-74.105120` #### Example 2 - Entry: `my_plugin/routing.js` - Args: `node my_plugin/routing.js --baz 10 --qux hello!` -Result: `node my_plugin/routing.js --baz 10 --qux hello! --input 40.780374,-73.969161 40.252042,-73.882841 40.256022,-74.105120` +Result: `node my_plugin/routing.js --baz 10 --qux hello!` +Stdin: `40.780374,-73.969161 40.252042,-73.882841 40.256022,-74.105120` #### Example 3 - Entry: `test.ts` - Args: `bun` -Result: `bun test.ts --input 40.780374,-73.969161 40.252042,-73.882841 40.256022,-74.105120` +Result: `bun test.ts` +Stdin: `40.780374,-73.969161 40.252042,-73.882841 40.256022,-74.105120` The main takeaway is that the first and second arguments are optional. Once Kōji finds the first argument that is prefixed by `--`, it now assumes that the rest of the arguments are meant to be passed to the plugin. If your plugin is only a single file, you can omit the interpreter and file path, or you can just omit the file path, however you can not omit the interpreter and try to use a custom file path. ## Clustering -### Input Value +### stdin Value -The input value for a clustering plugin is a stringified list of points of `n` length: `lat,lng lat,lng lat,lng ...`. These are the points that are to be clustered, e.g. spawnpoints or forts. +The stdin value for a clustering plugin is a stringified list of points of `n` length: `lat,lng lat,lng lat,lng ...`. These are the points that are to be clustered, e.g. spawnpoints or forts. ### Automatically Appended Args: - radius - min_points - max_clusters -- input ### Example Usage @@ -144,13 +146,9 @@ Set your options like so in the Kōji client drawer: ## Routing -### Input Value +### stdin Value -The input value for the routing plugin is a stringified list of points of `n` length: `lat,lng lat,lng lat,lng ...`. These are the cluster values. - -### Automatically Appended Args: - -- input +The stdin value for the routing plugin is a stringified list of points of `n` length: `lat,lng lat,lng lat,lng ...`. These are the cluster values. ### Example Usage @@ -183,14 +181,13 @@ Set your options like so in the Kōji client drawer: ## Bootstrapping -### Input Value +### stdin Value -The input value for the bootstrapping plugin is a GeoJSON Feature of either a Polygon or MultiPolygon type. This is the area that will be used to constrain the points that the bootstrap algorithm generates. +The stdin value for the bootstrapping plugin is a GeoJSON Feature of either a Polygon or MultiPolygon type. This is the area that will be used to constrain the points that the bootstrap algorithm generates. ### Automatically Appended Args: - radius -- input ### Example Usage diff --git a/or-tools/tsp/tsp.cc b/or-tools/tsp/tsp.cc index 0914002c..0de76326 100644 --- a/or-tools/tsp/tsp.cc +++ b/or-tools/tsp/tsp.cc @@ -172,29 +172,26 @@ int main(int argc, char *argv[]) RawInput points; std::vector stringPoints; + std::string line; + while (std::getline(std::cin, line, ' ') && !line.empty()) + { + auto coordinates = split(line, ','); + if (coordinates.size() == 2) + { + double lat = std::stod(coordinates[0]); + double lng = std::stod(coordinates[1]); + points.push_back({lat, lng}); + stringPoints.push_back(line); + } + } + for (int i = 1; i < argc; ++i) { std::string arg = argv[i]; if (arg.find("--") == 0) { std::string key = arg.substr(2); - if (key == "input") - { - std::string pointsStr = argv[++i]; - std::vector pointStrings = split(pointsStr, ' '); - for (const auto &pointStr : pointStrings) - { - auto coordinates = split(pointStr, ','); - if (coordinates.size() == 2) - { - double lat = std::stod(coordinates[0]); - double lng = std::stod(coordinates[1]); - points.push_back({lat, lng}); - stringPoints.push_back(pointStr); - } - } - } - else if (i + 1 < argc) + if (i + 1 < argc) { args[key] = argv[++i]; } diff --git a/server/algorithms/src/plugin.rs b/server/algorithms/src/plugin.rs index dd7df4a7..68963fd6 100644 --- a/server/algorithms/src/plugin.rs +++ b/server/algorithms/src/plugin.rs @@ -1,5 +1,5 @@ use std::fmt::Display; -use std::io::{self, BufRead, BufReader}; +use std::io::{self, BufRead, BufReader, Write}; use std::path::Path; use std::process::{Command, Stdio}; use std::time::Instant; @@ -67,7 +67,7 @@ impl Plugin { )); } let mut interpreter = match plugin.split(".").last() { - Some("py") => "python", + Some("py") => "python3", Some("js") => "node", Some("sh") => "bash", Some("ts") => "ts-node", @@ -113,7 +113,8 @@ impl Plugin { std::io::ErrorKind::NotFound, format!("{plugin} is a directory, not a file, something may not be right with the provided args"), )); - } else if path.exists() { + } + if path.exists() { plugin_path = path.display().to_string(); log::info!("{interpreter} {plugin_path} {}", args.join(" ")); } else { @@ -175,7 +176,6 @@ impl Plugin { }; let mut child = match child .args(self.args.iter()) - .args(&["--input", &input]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() @@ -184,6 +184,28 @@ impl Plugin { Err(err) => return Err(err), }; + let mut stdin = match child.stdin.take() { + Some(stdin) => stdin, + None => { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Failed to open stdin", + )); + } + }; + + std::thread::spawn(move || match stdin.write_all(input.as_bytes()) { + Ok(_) => match stdin.flush() { + Ok(_) => {} + Err(err) => { + log::error!("failed to flush stdin: {}", err); + } + }, + Err(err) => { + log::error!("failed to write to stdin: {}", err) + } + }); + let stdout = child .stdout .take() From dd40f6ece96357b8ecf6cdefb125b622d10cfa3b Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Wed, 28 Feb 2024 13:48:17 -0500 Subject: [PATCH 19/24] fix: normalize old logs --- server/Cargo.lock | 1 + server/algorithms/src/utils.rs | 4 ++-- server/api/src/private/instance.rs | 8 ++++---- server/api/src/private/points.rs | 20 ++++++++------------ server/api/src/public/v1/geofence.rs | 18 ++++++------------ server/api/src/public/v1/route.rs | 16 +++++----------- server/model/src/api/mod.rs | 2 +- server/model/src/api/multi_struct.rs | 2 +- server/model/src/api/multi_vec.rs | 2 +- server/model/src/api/poracle.rs | 2 +- server/model/src/api/single_struct.rs | 2 +- server/model/src/db/project.rs | 2 +- server/nominatim/Cargo.toml | 1 + server/nominatim/src/reverse.rs | 10 +++------- 14 files changed, 36 insertions(+), 54 deletions(-) diff --git a/server/Cargo.lock b/server/Cargo.lock index d414b157..6db1e2d2 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -2363,6 +2363,7 @@ version = "1.1.0" dependencies = [ "derive_builder", "geojson", + "log", "reqwest", "serde", "serde_json", diff --git a/server/algorithms/src/utils.rs b/server/algorithms/src/utils.rs index 191e708e..5df424f4 100644 --- a/server/algorithms/src/utils.rs +++ b/server/algorithms/src/utils.rs @@ -29,7 +29,7 @@ where content = content.trim_end_matches(",").to_string(); let mut output = File::create(path)?; write!(output, "{}", content)?; - // println!("Saved {} to file with {} coords", file_name, input.len()); + // log::info!("Saved {} to file with {} coords", file_name, input.len()); Ok(()) } @@ -38,7 +38,7 @@ pub fn debug_string(file_name: &str, input: &String) -> std::io::Result<()> { let path = format!("./debug_files/{}", file_name); let mut output = File::create(path)?; write!(output, "{}", input)?; - // println!("Saved {} to file with {} coords", file_name, input.len()); + // log::info!("Saved {} to file with {} coords", file_name, input.len()); Ok(()) } diff --git a/server/api/src/private/instance.rs b/server/api/src/private/instance.rs index f8d5e93e..9e71efde 100644 --- a/server/api/src/private/instance.rs +++ b/server/api/src/private/instance.rs @@ -18,7 +18,7 @@ use crate::{ #[get("/from_scanner")] async fn from_scanner(conn: web::Data) -> Result { - log::info!("\n[INSTANCE-ALL] Scanner Type: {}", conn.scanner_type); + log::info!("[INSTANCE-ALL] Scanner Type: {}", conn.scanner_type); let instances = if conn.scanner_type == ScannerType::Unown { area::Query::all(&conn.controller).await @@ -27,7 +27,7 @@ async fn from_scanner(conn: web::Data) -> Result { } .map_err(actix_web::error::ErrorInternalServerError)?; - log::info!("[INSTANCE_ALL] Returning {} instances\n", instances.len()); + log::info!("[INSTANCE_ALL] Returning {} instances", instances.len()); Ok(HttpResponse::Ok().json(Response { data: Some(json!(instances)), message: "ok".to_string(), @@ -39,7 +39,7 @@ async fn from_scanner(conn: web::Data) -> Result { #[get("/from_koji")] async fn from_koji(conn: web::Data) -> Result { - log::info!("\n[INSTANCE-ALL] Scanner Type: {}", conn.scanner_type); + log::info!("[INSTANCE-ALL] Scanner Type: {}", conn.scanner_type); let fences = geofence::Query::get_all_no_fences(&conn.koji) .await @@ -66,7 +66,7 @@ async fn from_koji(conn: web::Data) -> Result { }) }); - log::info!("[INSTANCE_ALL] Returning {} instances\n", fences.len()); + log::info!("[INSTANCE_ALL] Returning {} instances", fences.len()); Ok(HttpResponse::Ok().json(Response { data: Some(json!(fences)), message: "ok".to_string(), diff --git a/server/api/src/private/points.rs b/server/api/src/private/points.rs index 001fbca3..2b5e4eb8 100644 --- a/server/api/src/private/points.rs +++ b/server/api/src/private/points.rs @@ -18,7 +18,7 @@ async fn all( let category = category.into_inner(); log::info!( - "\n[DATA_ALL] Scanner Type: {} | Category: {}", + "[DATA_ALL] Scanner Type: {} | Category: {}", conn.scanner_type, category ); @@ -31,7 +31,7 @@ async fn all( } .map_err(actix_web::error::ErrorInternalServerError)?; - log::info!("[DATA-ALL] Returning {} {}s\n", all_data.len(), category); + log::info!("[DATA-ALL] Returning {} {}s", all_data.len(), category); Ok(HttpResponse::Ok().json(all_data)) } @@ -45,7 +45,7 @@ async fn bound( let payload = payload.into_inner(); log::info!( - "\n[DATA_BOUND] Scanner Type: {} | Category: {}", + "[DATA_BOUND] Scanner Type: {} | Category: {}", conn.scanner_type, category ); @@ -58,11 +58,7 @@ async fn bound( } .map_err(actix_web::error::ErrorInternalServerError)?; - log::info!( - "[DATA-BOUND] Returning {} {}s\n", - bound_data.len(), - category - ); + log::info!("[DATA-BOUND] Returning {} {}s", bound_data.len(), category); Ok(HttpResponse::Ok().json(bound_data)) } @@ -83,7 +79,7 @@ async fn by_area( } = payload.into_inner().init(None); log::info!( - "\n[DATA_AREA] Scanner Type: {} | Category: {}", + "[DATA_AREA] Scanner Type: {} | Category: {}", conn.scanner_type, category ); @@ -101,7 +97,7 @@ async fn by_area( .await .map_err(actix_web::error::ErrorInternalServerError)?; - log::info!("[DATA-AREA] Returning {} {}s\n", area_data.len(), category); + log::info!("[DATA-AREA] Returning {} {}s", area_data.len(), category); Ok(HttpResponse::Ok().json(area_data)) } @@ -122,7 +118,7 @@ async fn area_stats( } = payload.into_inner().init(None); log::info!( - "\n[DATA_AREA] Scanner Type: {} | Category: {}", + "[DATA_AREA] Scanner Type: {} | Category: {}", conn.scanner_type, category ); @@ -146,7 +142,7 @@ async fn area_stats( .map_err(actix_web::error::ErrorInternalServerError)?; log::info!( - "[DATA-AREA] Returning {} Total: {}\n", + "[DATA-AREA] Returning {} Total: {}", category, area_data.total ); diff --git a/server/api/src/public/v1/geofence.rs b/server/api/src/public/v1/geofence.rs index 4e0de9af..7c2fd8a4 100644 --- a/server/api/src/public/v1/geofence.rs +++ b/server/api/src/public/v1/geofence.rs @@ -24,7 +24,7 @@ async fn all( .await .map_err(actix_web::error::ErrorInternalServerError)?; - println!("[PUBLIC_API] Returning {} instances\n", fc.features.len()); + log::info!("[PUBLIC_API] Returning {} instances", fc.features.len()); Ok(HttpResponse::Ok().json(Response { data: Some(json!(fc)), message: "Success".to_string(), @@ -52,7 +52,7 @@ async fn get_area( .map_err(actix_web::error::ErrorInternalServerError)?; log::info!( - "[PUBLIC_API] Returning feature for {:?}\n", + "[PUBLIC_API] Returning feature for {:?}", feature.property("name") ); Ok(utils::response::send( @@ -112,7 +112,7 @@ async fn save_scanner( .await .map_err(actix_web::error::ErrorInternalServerError)?; } - println!("Rows Updated: {}, Rows Inserted: {}", updates, inserts); + log::info!("Rows Updated: {}, Rows Inserted: {}", updates, inserts); Ok(HttpResponse::Ok().json(Response { data: Some(json!({ "updates": updates, "inserts": inserts })), @@ -167,7 +167,7 @@ async fn reference_data(conn: web::Data) -> Result .await .map_err(actix_web::error::ErrorInternalServerError)?; - log::info!("[GEOFENCES_ALL] Returning {} instances\n", fences.len()); + log::info!("[GEOFENCES_ALL] Returning {} instances", fences.len()); Ok(HttpResponse::Ok().json(Response { data: Some(json!(fences)), message: "Success".to_string(), @@ -210,10 +210,7 @@ async fn specific_return_type( .await .map_err(actix_web::error::ErrorInternalServerError)?; - println!( - "[GEOFENCES_ALL] Returning {} instances\n", - fc.features.len() - ); + log::info!("[GEOFENCES_ALL] Returning {} instances", fc.features.len()); Ok(utils::response::send(fc, return_type, None, false, None)) } @@ -231,10 +228,7 @@ async fn specific_project( .await .map_err(actix_web::error::ErrorInternalServerError)?; - println!( - "[GEOFENCES_FC_ALL] Returning {} instances\n", - features.len() - ); + log::info!("[GEOFENCES_FC_ALL] Returning {} instances", features.len()); Ok(utils::response::send( features.to_collection(None, None), return_type, diff --git a/server/api/src/public/v1/route.rs b/server/api/src/public/v1/route.rs index e2c63d10..5ee65f2b 100644 --- a/server/api/src/public/v1/route.rs +++ b/server/api/src/public/v1/route.rs @@ -23,7 +23,7 @@ async fn all( .await .map_err(actix_web::error::ErrorInternalServerError)?; - log::info!("[PUBLIC_API] Returning {} routes\n", fc.features.len()); + log::info!("[PUBLIC_API] Returning {} routes", fc.features.len()); Ok(HttpResponse::Ok().json(Response { data: Some(json!(fc)), message: "Success".to_string(), @@ -51,7 +51,7 @@ async fn get_area( .map_err(actix_web::error::ErrorInternalServerError)?; log::info!( - "[PUBLIC_API] Returning feature for {:?}\n", + "[PUBLIC_API] Returning feature for {:?}", feature.property("name") ); Ok(utils::response::send( @@ -69,7 +69,7 @@ async fn reference_data(conn: web::Data) -> Result .await .map_err(actix_web::error::ErrorInternalServerError)?; - log::info!("[ROUTE_REF] Returning {} instances\n", fences.len()); + log::info!("[ROUTE_REF] Returning {} instances", fences.len()); Ok(HttpResponse::Ok().json(Response { data: Some(json!(fences)), message: "Success".to_string(), @@ -173,10 +173,7 @@ async fn specific_return_type( .await .map_err(actix_web::error::ErrorInternalServerError)?; - log::info!( - "[GEOFENCES_ALL] Returning {} instances\n", - fc.features.len() - ); + log::info!("[GEOFENCES_ALL] Returning {} instances", fc.features.len()); Ok(utils::response::send(fc, return_type, None, false, None)) } @@ -197,10 +194,7 @@ async fn specific_geofence( .await .map_err(actix_web::error::ErrorInternalServerError)?; - log::info!( - "[GEOFENCES_FC_ALL] Returning {} instances\n", - features.len() - ); + log::info!("[GEOFENCES_FC_ALL] Returning {} instances", features.len()); Ok(utils::response::send( features.to_collection(None, None), return_type, diff --git a/server/model/src/api/mod.rs b/server/model/src/api/mod.rs index cc1fca8b..f488c0f4 100644 --- a/server/model/src/api/mod.rs +++ b/server/model/src/api/mod.rs @@ -204,7 +204,7 @@ impl BBox { vec![self.max_x, self.min_y], vec![self.min_x, self.min_y], ]] - // println!( + // log::info!( // "{}, {}\n{}, {}\n{}, {}\n{}, {}\n{}, {}\n", // self.min_y, // self.min_x, diff --git a/server/model/src/api/multi_struct.rs b/server/model/src/api/multi_struct.rs index 8fce5fb7..bc7f4122 100644 --- a/server/model/src/api/multi_struct.rs +++ b/server/model/src/api/multi_struct.rs @@ -30,7 +30,7 @@ impl ToMultiVec for MultiStruct { impl ToPointStruct for MultiStruct { fn to_struct(self) -> point_struct::PointStruct { - println!("`to_struct()` was called on a SingleVec and this was likely unintentional, did you mean to map over the values first?"); + log::info!("`to_struct()` was called on a SingleVec and this was likely unintentional, did you mean to map over the values first?"); point_struct::PointStruct { lat: self[0][0].lat, lon: self[0][0].lon, diff --git a/server/model/src/api/multi_vec.rs b/server/model/src/api/multi_vec.rs index 77e33a73..51a3678f 100644 --- a/server/model/src/api/multi_vec.rs +++ b/server/model/src/api/multi_vec.rs @@ -85,7 +85,7 @@ impl ToMultiVec for MultiVec { impl ToPointStruct for MultiVec { fn to_struct(self) -> point_struct::PointStruct { - println!("`to_struct()` was called on a MultiVec and this was likely unintentional, did you mean to map over the values first?"); + log::warn!("`to_struct()` was called on a MultiVec and this was likely unintentional, did you mean to map over the values first?"); point_struct::PointStruct { lat: self[0][0][0], lon: self[1][0][1], diff --git a/server/model/src/api/poracle.rs b/server/model/src/api/poracle.rs index 1d628cf1..a4589e14 100644 --- a/server/model/src/api/poracle.rs +++ b/server/model/src/api/poracle.rs @@ -154,7 +154,7 @@ impl ToFeature for Poracle { UnknownId::String(id) => match id.parse::() { Ok(id) => id, Err(err) => { - println!("Parse Error: {:?}", err); + log::error!("Parse Error: {:?}", err); 0 } }, diff --git a/server/model/src/api/single_struct.rs b/server/model/src/api/single_struct.rs index 654ada4c..4c1352a0 100644 --- a/server/model/src/api/single_struct.rs +++ b/server/model/src/api/single_struct.rs @@ -31,7 +31,7 @@ impl ToMultiVec for SingleStruct { impl ToPointStruct for SingleStruct { fn to_struct(self) -> point_struct::PointStruct { - println!("`to_struct()` was called on a SingleVec and this was likely unintentional, did you mean to map over the values first?"); + log::warn!("`to_struct()` was called on a SingleVec and this was likely unintentional, did you mean to map over the values first?"); point_struct::PointStruct { lat: self[0].lat, lon: self[0].lon, diff --git a/server/model/src/db/project.rs b/server/model/src/db/project.rs index e0853f31..1a6c5bba 100644 --- a/server/model/src/db/project.rs +++ b/server/model/src/db/project.rs @@ -113,7 +113,7 @@ impl Query { let results: Vec = match paginator.fetch_page(args.page).await { Ok(results) => results, Err(err) => { - println!("[project] Error paginating, {:?}", err); + log::error!("[project] Error paginating, {:?}", err); vec![] } }; diff --git a/server/nominatim/Cargo.toml b/server/nominatim/Cargo.toml index c1c12bb8..a352ffb8 100644 --- a/server/nominatim/Cargo.toml +++ b/server/nominatim/Cargo.toml @@ -14,6 +14,7 @@ reqwest = "0.11.22" serde = { version = "1.0.189", features = ["derive"] } serde_json = "1.0.107" serde_urlencoded = "0.7.1" +log = "0.4.20" url = "2.4.1" derive_builder = "0.12.0" thiserror = "1.0.50" diff --git a/server/nominatim/src/reverse.rs b/server/nominatim/src/reverse.rs index c0096305..573c0183 100644 --- a/server/nominatim/src/reverse.rs +++ b/server/nominatim/src/reverse.rs @@ -1,8 +1,7 @@ use crate::client::Client; use crate::error::Error; use crate::serde_utils::{ - serialize_as_string, serialize_bool_as_string, - serialize_vector_as_string_opt, + serialize_as_string, serialize_bool_as_string, serialize_vector_as_string_opt, }; use crate::types::Response; use crate::util::RequestBuilderHelper; @@ -97,10 +96,7 @@ impl Client { /// always have a similar enough address to the coordinate you were /// requesting. For example, in dense city areas it may belong to a /// completely different street. - pub async fn reverse( - &self, - query: ReverseQuery, - ) -> Result { + pub async fn reverse(&self, query: ReverseQuery) -> Result { let mut url = self.base_url.join("reverse")?; url.set_query(Some(&serde_urlencoded::to_string(&query).unwrap())); @@ -114,7 +110,7 @@ impl Client { let text = response.text().await?; - println!("{}", text); + log::info!("{}", text); Ok(serde_json::from_str(&text)?) } From f3806731e5c7fa97d004cc5399dafb312a335f1c Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Wed, 28 Feb 2024 14:36:53 -0500 Subject: [PATCH 20/24] fix: support for parsing single stdout lines --- server/algorithms/src/plugin.rs | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/server/algorithms/src/plugin.rs b/server/algorithms/src/plugin.rs index 68963fd6..750e861a 100644 --- a/server/algorithms/src/plugin.rs +++ b/server/algorithms/src/plugin.rs @@ -44,6 +44,9 @@ trait ParseCoord { impl ParseCoord for std::str::Split<'_, &str> { fn parse_next_coord(&mut self) -> Option { if let Some(coord) = self.next() { + if coord.contains(" ") { + return coord.split(",").parse_next_coord(); + } if let Ok(coord) = coord.parse::() { return Some(coord); } @@ -217,13 +220,27 @@ impl Plugin { for line in reader.lines() { match line { Ok(line) => { - let mut iter: std::str::Split<'_, &str> = line.trim().split(","); - let lat = iter.parse_next_coord(); - let lng = iter.parse_next_coord(); - if lat.is_none() || lng.is_none() { - invalid.push(line) + if line.contains(" ") { + let mut iter: std::str::Split<'_, &str> = line.trim().split(" "); + while let Some(line) = iter.next() { + let mut coord: std::str::Split<'_, &str> = line.trim().split(","); + let lat = coord.parse_next_coord(); + let lng = coord.parse_next_coord(); + if lat.is_none() || lng.is_none() { + invalid.push(line.to_string()) + } else { + results.push([lat.unwrap(), lng.unwrap()]) + } + } } else { - results.push([lat.unwrap(), lng.unwrap()]) + let mut iter: std::str::Split<'_, &str> = line.trim().split(","); + let lat = iter.parse_next_coord(); + let lng = iter.parse_next_coord(); + if lat.is_none() || lng.is_none() { + invalid.push(line) + } else { + results.push([lat.unwrap(), lng.unwrap()]) + } } } Err(e) => { From 373649466dd54818b4d35bcf87362a9fe41448a8 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Wed, 28 Feb 2024 14:37:05 -0500 Subject: [PATCH 21/24] fix: add plugin examples to docs --- docs/pages/plugins.mdx | 2 - docs/pages/plugins/examples.mdx | 84 +++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 docs/pages/plugins/examples.mdx diff --git a/docs/pages/plugins.mdx b/docs/pages/plugins.mdx index 967729b3..8f7405db 100644 --- a/docs/pages/plugins.mdx +++ b/docs/pages/plugins.mdx @@ -221,5 +221,3 @@ Set your options like so in the Kōji client drawer: The results of your plugin must be returned to Kōji via stdout. The results must be a stringified list of points of `n` length: `lat,lng lat,lng lat,lng ...`. In Python for example, this is as simple as printing the results. While Kōji attempts to filter out any unnecessary or invalid text that was logged, it's best not to log anything other than the final results. ## Plugin Example - -You can already view a live plugin example as the OR-Tools integration (TSP) has been reworker to be used with this plugin system. You can view the source code [here](https://github.com/TurtIeSocks/Koji/blob/main/or-tools/tsp/tsp.cc). diff --git a/docs/pages/plugins/examples.mdx b/docs/pages/plugins/examples.mdx new file mode 100644 index 00000000..7084b91e --- /dev/null +++ b/docs/pages/plugins/examples.mdx @@ -0,0 +1,84 @@ +# Plugin Examples + +## C++ + +The TSP routing method is actually a plugin and can be viewed in the codebase [here](https://github.com/TurtIeSocks/Koji/blob/main/or-tools/tsp/tsp.cc). + +## Python + +```py +import argparse + +def parse_points(points_str): + points = [] + for point in points_str.split(): + lat, lng = map(float, point.split(",")) + points.append((lat, lng)) + return points + +def main(): + # Parse Arguments + parser = argparse.ArgumentParser(description="Route Points") + parser.add_argument( + "--radius", + type=int, + help="Radius of the cluster", + ) + parser.add_argument( + "--split-level", + type=int, + help="A way to split stuff", + ) + args = parser.parse_args() + + # Read points from stdin: + points_str = input() + points = parse_points(points_str) + + # Do something with the points and args here # + + for point in points: + print(f"{point[0]},{point[1]}") + +if __name__ == "__main__": + main() +``` + +## JavaScript + +```js +// Parse input args +const args = [] +for (let i = 2; i < process.argv.length; i++) { + if (process.argv[i].startsWith('--')) { + if (process.argv[i + 1]) { + const arg = process.argv[i] + const value = process.argv[++i] + const maybeNumber = +value + args.push({ [arg]: Number.isInteger(maybeNumber) ? maybeNumber : value }) + } + } +} + +process.stdin.on('data', function (data) { + // Parse the stdin from Kōji + const coords = data + .toString() + .trim() + .split(' ') + .map((coord) => coord.split(',').map(Number)) + + // Do something with coords and args // + + // Send results back to Kōji + + // You can return points individually like this + for (const point of coords) { + process.stdout.write(`${point.join(',')} `) + } + + // Or you can return them as a single string + const result = coords.map((coord) => coord.join(',')).join(' ') + process.stdout.write(result) +}) +``` From ff4238ec9edfd53c07ecec78590ce77479d190e2 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Wed, 28 Feb 2024 14:39:14 -0500 Subject: [PATCH 22/24] fix: add return point length after plugin completes --- server/algorithms/src/plugin.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/algorithms/src/plugin.rs b/server/algorithms/src/plugin.rs index 750e861a..1b4b57df 100644 --- a/server/algorithms/src/plugin.rs +++ b/server/algorithms/src/plugin.rs @@ -283,9 +283,10 @@ impl Plugin { )) } else { log::info!( - "{} child process finished in {}s", + "{} child process finished in {}s with {} points", self.plugin, - time.elapsed().as_secs_f32() + time.elapsed().as_secs_f32(), + results.len() ); // Ok(output_indexes.into_iter().map(|i| points[i]).collect()) Ok(results) From 81bcb2297d43c7cf5657ceece3909ed198c2bd47 Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Wed, 28 Feb 2024 14:43:14 -0500 Subject: [PATCH 23/24] fix: doc c&p bug --- docs/pages/plugins/examples.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pages/plugins/examples.mdx b/docs/pages/plugins/examples.mdx index 7084b91e..31ef6674 100644 --- a/docs/pages/plugins/examples.mdx +++ b/docs/pages/plugins/examples.mdx @@ -74,7 +74,7 @@ process.stdin.on('data', function (data) { // You can return points individually like this for (const point of coords) { - process.stdout.write(`${point.join(',')} `) + process.stdout.write(`${point.join(',')}`) } // Or you can return them as a single string From ea55f54f20909969dc3ad01b1cb8fcc8faa1ad3d Mon Sep 17 00:00:00 2001 From: Derick M <58572875+TurtIeSocks@users.noreply.github.com> Date: Wed, 28 Feb 2024 14:46:37 -0500 Subject: [PATCH 24/24] fix: log simplifcation --- server/algorithms/src/plugin.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/algorithms/src/plugin.rs b/server/algorithms/src/plugin.rs index 1b4b57df..f96fe546 100644 --- a/server/algorithms/src/plugin.rs +++ b/server/algorithms/src/plugin.rs @@ -119,7 +119,11 @@ impl Plugin { } if path.exists() { plugin_path = path.display().to_string(); - log::info!("{interpreter} {plugin_path} {}", args.join(" ")); + if interpreter == plugin_path { + log::info!("{interpreter} {}", args.join(" ")); + } else { + log::info!("{interpreter} {plugin_path} {}", args.join(" ")); + } } else { return Err(std::io::Error::new( std::io::ErrorKind::NotFound,