feat(rad): FRA engine phase 2b — mandatory arrival transitions + top-2 repair
Parse Annex 2B mandatory arrival routings and repair the best few
candidates (not just the lowest-error one):
- core::rad: MandatoryArr + RadData.mandatory_arr +
mandatory_arr_transitions(ades) -> allowed point-sequences.
- rad crate: parse_mandatory_arr() (VIA-clauses in NOT-AVBL-EXC rows,
scoped to ARR airports). via_sequences() now scans all parenthesised
transition groups (contain DCT/airway), not just contiguous "VIA (",
because sub-option markers separate them ("VIA i) (KARDU DCT KALMO)").
rad-tool `manarr [ADES]`. LIML now yields KARDU KALMO etc.
- discover: "manarr" candidates route to the transition's on-airway
anchor then splice the (often off-airway) transition points in via DCT.
- attempt_once repairs the top-2 candidates and keeps the best result:
the lowest-raw-error candidate isn't always the most repairable (a
mandate-satisfying route may carry more mechanical errors than a route
stuck on an unfixable mandatory).
Collapses the dense-FRA near-misses: EGLL-EDDM 7->1 err, EDDF-LIRF 3->1
(mandatory PROF205 resolved; residual is a single ROUTE49). Coverage 6/10,
no regression (passing pairs short-circuit on accept). 49 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -393,6 +393,7 @@ mod tests {
|
|||||||
fra_points: vec![],
|
fra_points: vec![],
|
||||||
forbidden_segs: vec![],
|
forbidden_segs: vec![],
|
||||||
mandatory_dep: vec![],
|
mandatory_dep: vec![],
|
||||||
|
mandatory_arr: vec![],
|
||||||
};
|
};
|
||||||
let r = prevalidate(&conn, "ABEAM DCT DOVER", 200, Some(&rad)).unwrap();
|
let r = prevalidate(&conn, "ABEAM DCT DOVER", 200, Some(&rad)).unwrap();
|
||||||
assert!(!r.accepted, "RAD should reject the forbidden direct");
|
assert!(!r.accepted, "RAD should reject the forbidden direct");
|
||||||
|
|||||||
@@ -156,6 +156,20 @@ pub struct MandatoryDep {
|
|||||||
pub allowed: Vec<Vec<String>>,
|
pub allowed: Vec<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A **mandatory arrival routing** (Annex 2B): traffic arriving at `airports`
|
||||||
|
/// must approach via one of the `allowed` transition point-sequences (each ending
|
||||||
|
/// near the destination). The structured form of `PROF205 … VIA <pt> IS OFF
|
||||||
|
/// MANDATORY ROUTE` where the point is an arrival gateway. Within a row the DEP
|
||||||
|
/// gating is dropped in v1 (candidates are additive, the oracle filters).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct MandatoryArr {
|
||||||
|
pub id: String,
|
||||||
|
/// Arrival airports/groups the rule applies to.
|
||||||
|
pub airports: Vec<String>,
|
||||||
|
/// Allowed arrival transition point-sequences, e.g. `[KARDU,KALMO]`.
|
||||||
|
pub allowed: Vec<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// The parsed RAD (the parts we currently model).
|
/// The parsed RAD (the parts we currently model).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct RadData {
|
pub struct RadData {
|
||||||
@@ -176,6 +190,9 @@ pub struct RadData {
|
|||||||
/// Mandatory departure routings (Annex 3A DEP COMPULSORY); empty if not parsed.
|
/// Mandatory departure routings (Annex 3A DEP COMPULSORY); empty if not parsed.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub mandatory_dep: Vec<MandatoryDep>,
|
pub mandatory_dep: Vec<MandatoryDep>,
|
||||||
|
/// Mandatory arrival routings (Annex 2B); empty if not parsed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub mandatory_arr: Vec<MandatoryArr>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RadData {
|
impl RadData {
|
||||||
@@ -275,6 +292,24 @@ impl RadData {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Allowed mandatory arrival transition point-sequences for `ades` (Annex 2B).
|
||||||
|
pub fn mandatory_arr_transitions(&self, ades: &str) -> Vec<Vec<String>> {
|
||||||
|
let applies = |set: &[String]| -> bool {
|
||||||
|
set.iter().any(|s| {
|
||||||
|
s.eq_ignore_ascii_case(ades)
|
||||||
|
|| self.areas.iter().any(|a| {
|
||||||
|
a.id.eq_ignore_ascii_case(s)
|
||||||
|
&& a.airports.iter().any(|ap| ap.eq_ignore_ascii_case(ades))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
self.mandatory_arr
|
||||||
|
.iter()
|
||||||
|
.filter(|m| applies(&m.airports))
|
||||||
|
.flat_map(|m| m.allowed.iter().cloned())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Count of restrictions by kind — for summaries/status.
|
/// Count of restrictions by kind — for summaries/status.
|
||||||
pub fn dct_counts(&self) -> (usize, usize, usize) {
|
pub fn dct_counts(&self) -> (usize, usize, usize) {
|
||||||
let mut forbidden = 0;
|
let mut forbidden = 0;
|
||||||
@@ -319,6 +354,7 @@ mod tests {
|
|||||||
fra_points: vec![],
|
fra_points: vec![],
|
||||||
forbidden_segs: vec![],
|
forbidden_segs: vec![],
|
||||||
mandatory_dep: vec![],
|
mandatory_dep: vec![],
|
||||||
|
mandatory_arr: vec![],
|
||||||
};
|
};
|
||||||
assert!(rad.forbidden_dct("ABC", "DEF", 200).is_some());
|
assert!(rad.forbidden_dct("ABC", "DEF", 200).is_some());
|
||||||
assert!(rad.forbidden_dct("abc", "def", 200).is_some()); // case-insensitive
|
assert!(rad.forbidden_dct("abc", "def", 200).is_some()); // case-insensitive
|
||||||
@@ -351,6 +387,7 @@ mod tests {
|
|||||||
fra_points: vec![],
|
fra_points: vec![],
|
||||||
forbidden_segs: vec![],
|
forbidden_segs: vec![],
|
||||||
mandatory_dep: vec![],
|
mandatory_dep: vec![],
|
||||||
|
mandatory_arr: vec![],
|
||||||
};
|
};
|
||||||
// LFPG→LSGG matches R1 (via group) and R2 → min cap 295.
|
// LFPG→LSGG matches R1 (via group) and R2 → min cap 295.
|
||||||
assert_eq!(rad.max_cruise_fl("LFPG", "LSGG"), Some(295));
|
assert_eq!(rad.max_cruise_fl("LFPG", "LSGG"), Some(295));
|
||||||
|
|||||||
@@ -601,6 +601,45 @@ fn route_via_transition(
|
|||||||
Ok(Some(Route { legs, total_nm: total, via_airways: true }))
|
Ok(Some(Route { legs, total_nm: total, via_airways: true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a candidate that arrives via a mandatory transition sequence (Annex 2B):
|
||||||
|
/// route from `from` into the network at the transition's first point, then DCT
|
||||||
|
/// through the transition points to `to`. `None` if positions/routing don't
|
||||||
|
/// resolve. Satisfies `PROF205 … VIA <arrival pt> IS OFF MANDATORY ROUTE`.
|
||||||
|
fn route_via_arrival_transition(
|
||||||
|
conn: &Connection,
|
||||||
|
from: &str,
|
||||||
|
to: &str,
|
||||||
|
transition: &[String],
|
||||||
|
fl: i32,
|
||||||
|
dep_fixes: &[String],
|
||||||
|
avoid: &std::collections::HashSet<String>,
|
||||||
|
) -> Result<Option<Route>> {
|
||||||
|
// Anchor on the transition's LAST point — arrival transitions end at an
|
||||||
|
// on-airway gateway (e.g. KALMO), while the earlier points may be off-airway
|
||||||
|
// FRA fixes (e.g. KARDU) that can't be graph connectors. Route to the anchor,
|
||||||
|
// then splice the whole transition in via DCT ahead of it.
|
||||||
|
let Some(anchor) = transition.last() else { return Ok(None) };
|
||||||
|
let head = super::plan_route_best_avoiding(conn, from, to, Some(fl), dep_fixes, std::slice::from_ref(anchor), avoid)?;
|
||||||
|
let Some(idx) = head.legs.iter().position(|l| l.to.eq_ignore_ascii_case(anchor)) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let dst = super::airport_pos(conn, to)?;
|
||||||
|
let x = head.legs[idx].from.clone(); // on-airway point just before the anchor
|
||||||
|
let mut prev = x.clone();
|
||||||
|
let mut prevpos = super::ident_pos(conn, &x, dst).unwrap_or(dst);
|
||||||
|
// Replace the `x → anchor` leg with `x → t0 → … → anchor` (all DCT).
|
||||||
|
let mut legs: Vec<Leg> = head.legs[..idx].to_vec();
|
||||||
|
for p in transition {
|
||||||
|
let Some(pos) = super::ident_pos(conn, p, dst) else { return Ok(None) };
|
||||||
|
legs.push(Leg { from: prev.clone(), to: p.to_uppercase(), airway: "DCT".into(), dist_nm: prevpos.distance_nm(&pos) });
|
||||||
|
prev = p.to_uppercase();
|
||||||
|
prevpos = pos;
|
||||||
|
}
|
||||||
|
legs.extend_from_slice(&head.legs[idx + 1..]); // anchor → … → to
|
||||||
|
let total = total_nm(&legs);
|
||||||
|
Ok(Some(Route { legs, total_nm: total, via_airways: true }))
|
||||||
|
}
|
||||||
|
|
||||||
/// One discovery attempt with a fixed set of allowed gateways: generate the seed
|
/// One discovery attempt with a fixed set of allowed gateways: generate the seed
|
||||||
/// / FRA / airway candidates, start the repair loop from whichever the oracle
|
/// / FRA / airway candidates, start the repair loop from whichever the oracle
|
||||||
/// rates best.
|
/// rates best.
|
||||||
@@ -680,26 +719,68 @@ fn attempt_once(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Mandatory arrival transitions (Annex 2B): offer candidates that arrive
|
||||||
|
// via an allowed transition. The parser over-associates (all VIA-seqs in a
|
||||||
|
// row with every ARR airport), so keep only transitions that actually end
|
||||||
|
// near the destination, then pick the shortest overall. Additive.
|
||||||
|
let mut arr = rad.mandatory_arr_transitions(to);
|
||||||
|
arr.dedup();
|
||||||
|
if let (Ok(dep), Ok(dst)) = (super::airport_pos(conn, from), super::airport_pos(conn, to)) {
|
||||||
|
// Rank by estimated total length (dep→first + first→last + last→dest);
|
||||||
|
// this pushes the over-associated transitions that don't end near the
|
||||||
|
// destination to the back without a hard cut-off.
|
||||||
|
let est = |s: &Vec<String>| -> f64 {
|
||||||
|
match (
|
||||||
|
s.first().and_then(|p| super::ident_pos(conn, p, dep)),
|
||||||
|
s.last().and_then(|p| super::ident_pos(conn, p, dst)),
|
||||||
|
) {
|
||||||
|
(Some(f), Some(l)) => dep.distance_nm(&f) + f.distance_nm(&l) + l.distance_nm(&dst),
|
||||||
|
_ => f64::MAX,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
arr.sort_by(|a, b| est(a).total_cmp(&est(b)));
|
||||||
|
}
|
||||||
|
for t in arr.iter().take(4) {
|
||||||
|
if let Ok(Some(r)) = route_via_arrival_transition(conn, from, to, t, start_fl, &dep_fixes, &avoid) {
|
||||||
|
if r.legs.len() >= 2 {
|
||||||
|
candidates.push(("manarr", r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut pre_log: Vec<String> = Vec::new();
|
let mut pre_log: Vec<String> = Vec::new();
|
||||||
let mut chosen: Option<(Route, IfpsVerdict)> = None;
|
let mut scored: Vec<(Route, IfpsVerdict)> = Vec::new();
|
||||||
for (tag, cand) in candidates {
|
for (tag, cand) in candidates {
|
||||||
let v = validator.validate(from, to, &route_item15(&cand), start_fl)?;
|
let v = validator.validate(from, to, &route_item15(&cand), start_fl)?;
|
||||||
pre_log.push(format!("candidate {tag}: {} err{}", v.errors.len(), if v.accepted { " (ACCEPTED)" } else { "" }));
|
pre_log.push(format!("candidate {tag}: {} err{}", v.errors.len(), if v.accepted { " (ACCEPTED)" } else { "" }));
|
||||||
let better = chosen.as_ref().map(|(_, cv)| v.errors.len() < cv.errors.len()).unwrap_or(true);
|
if v.accepted {
|
||||||
let accepted = v.accepted;
|
let mut res = run_loop(conn, from, to, &dep_fixes, &dest_fixes, cand, v, start_fl, std::mem::take(&mut avoid), validator)?;
|
||||||
if better {
|
pre_log.append(&mut res.log);
|
||||||
chosen = Some((cand, v));
|
res.log = pre_log;
|
||||||
|
return Ok(res);
|
||||||
}
|
}
|
||||||
if accepted {
|
scored.push((cand, v));
|
||||||
|
}
|
||||||
|
if scored.is_empty() {
|
||||||
|
return Err(crate::error::CoreError::Routing("no route candidate".into()));
|
||||||
|
}
|
||||||
|
// The lowest-error candidate isn't always the most repairable: a mandate-
|
||||||
|
// satisfying route (mandep/manarr) may carry more mechanical errors than a
|
||||||
|
// shorter route that is stuck on an unfixable mandatory. So repair the best
|
||||||
|
// few candidates and keep whichever result is best.
|
||||||
|
scored.sort_by_key(|(_, v)| v.errors.len());
|
||||||
|
let mut best: Option<DiscoverResult> = None;
|
||||||
|
for (cand, verdict) in scored.into_iter().take(2) {
|
||||||
|
let res = run_loop(conn, from, to, &dep_fixes, &dest_fixes, cand, verdict, start_fl, avoid.clone(), validator)?;
|
||||||
|
if best.as_ref().map_or(true, |b| res.accepted || res.errors.len() < b.errors.len()) {
|
||||||
|
best = Some(res);
|
||||||
|
}
|
||||||
|
if best.as_ref().is_some_and(|b| b.accepted) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let (initial, verdict) =
|
let mut res = best.expect("scored is non-empty");
|
||||||
chosen.ok_or_else(|| crate::error::CoreError::Routing("no route candidate".into()))?;
|
|
||||||
|
|
||||||
let mut res = run_loop(conn, from, to, &dep_fixes, &dest_fixes, initial, verdict, start_fl, std::mem::take(&mut avoid), validator)?;
|
|
||||||
pre_log.append(&mut res.log);
|
pre_log.append(&mut res.log);
|
||||||
res.log = pre_log;
|
res.log = pre_log;
|
||||||
Ok(res)
|
Ok(res)
|
||||||
|
|||||||
+47
-6
@@ -10,7 +10,8 @@ use calamine::{open_workbook, Data, Reader, Xlsx};
|
|||||||
// The RAD data model lives in `core` (so routing/validation can use it without
|
// The RAD data model lives in `core` (so routing/validation can use it without
|
||||||
// pulling in `calamine`); this crate produces those types.
|
// pulling in `calamine`); this crate produces those types.
|
||||||
pub use flightplanner_core::rad::{
|
pub use flightplanner_core::rad::{
|
||||||
Area, DctKind, DctRestriction, ForbiddenSeg, FraEdge, FraPoint, LevelCap, MandatoryDep, RadData,
|
Area, DctKind, DctRestriction, ForbiddenSeg, FraEdge, FraPoint, LevelCap, MandatoryArr,
|
||||||
|
MandatoryDep, RadData,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Parse the official EUROCONTROL "FRA Points" list (a separate `.xlsx` — see the
|
/// Parse the official EUROCONTROL "FRA Points" list (a separate `.xlsx` — see the
|
||||||
@@ -119,9 +120,35 @@ pub fn parse(path: &str) -> Result<RadData> {
|
|||||||
fra_points: Vec::new(), // loaded separately via parse_fra_points
|
fra_points: Vec::new(), // loaded separately via parse_fra_points
|
||||||
forbidden_segs: parse_forbidden_segments(path)?,
|
forbidden_segs: parse_forbidden_segments(path)?,
|
||||||
mandatory_dep: parse_mandatory_dep(path)?,
|
mandatory_dep: parse_mandatory_dep(path)?,
|
||||||
|
mandatory_arr: parse_mandatory_arr(path)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Annex 2B — mandatory arrival routings. Rows whose utilization lists allowed
|
||||||
|
/// `VIA (...)` transitions as exceptions to a `NOT AVBL FOR TFC` ban, scoped to
|
||||||
|
/// one or more `ARR <airport>`s (e.g. LI2137: `ARR LIML VIA (KARDU DCT KALMO)`).
|
||||||
|
/// v1 associates every VIA-sequence in a row with every ARR airport in it (the
|
||||||
|
/// DEP gating is dropped — candidates are additive, so the oracle filters).
|
||||||
|
pub fn parse_mandatory_arr(path: &str) -> Result<Vec<MandatoryArr>> {
|
||||||
|
let Ok(rows) = rows(path, "Annex 2B") else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for r in rows.iter().skip(1) {
|
||||||
|
let up = cell(r, 8).to_uppercase().replace(['\r'], " ");
|
||||||
|
if !up.contains("NOT AVBL FOR TFC") || !up.contains("ARR ") || !up.contains("VIA (") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let airports = apt_after(&up, "ARR");
|
||||||
|
let allowed: Vec<Vec<String>> = via_sequences(&up).into_iter().filter(|s| !s.is_empty()).collect();
|
||||||
|
if airports.is_empty() || allowed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(MandatoryArr { id: cell(r, 3), airports, allowed });
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
/// Annex 3A DEP — mandatory departure routings (`COMPULSORY FOR TFC`). Extracts
|
/// Annex 3A DEP — mandatory departure routings (`COMPULSORY FOR TFC`). Extracts
|
||||||
/// the allowed transition point-sequences from the `VIA (...)` clauses, chaining
|
/// the allowed transition point-sequences from the `VIA (...)` clauses, chaining
|
||||||
/// sub-fragments (`d. VIA (OPALE DCT KESAX)` + `ii) VIA (KESAX DCT DIMAL DCT
|
/// sub-fragments (`d. VIA (OPALE DCT KESAX)` + `ii) VIA (KESAX DCT DIMAL DCT
|
||||||
@@ -151,14 +178,20 @@ pub fn parse_mandatory_dep(path: &str) -> Result<Vec<MandatoryDep>> {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The point-sequences inside every `VIA (...)` clause (balanced parens), dropping
|
/// Point-sequences from every parenthesised transition clause (balanced parens
|
||||||
/// `DCT`/airways/keywords — just the ordered enroute points.
|
/// that contain a `DCT` or an airway token — which skips airport lists like
|
||||||
|
/// `(LIMJ, LIRP)` and engine-type notes like `(J)`). We can't anchor on `VIA (`
|
||||||
|
/// because sub-option markers separate them (`VIA i) (KARDU DCT KALMO)`).
|
||||||
fn via_sequences(up: &str) -> Vec<Vec<String>> {
|
fn via_sequences(up: &str) -> Vec<Vec<String>> {
|
||||||
let bytes = up.as_bytes();
|
let bytes = up.as_bytes();
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while let Some(p) = up[i..].find("VIA (") {
|
while i < bytes.len() {
|
||||||
let start = i + p + 5;
|
if bytes[i] != b'(' {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let start = i + 1;
|
||||||
let mut depth = 1;
|
let mut depth = 1;
|
||||||
let mut j = start;
|
let mut j = start;
|
||||||
while j < bytes.len() && depth > 0 {
|
while j < bytes.len() && depth > 0 {
|
||||||
@@ -170,7 +203,15 @@ fn via_sequences(up: &str) -> Vec<Vec<String>> {
|
|||||||
j += 1;
|
j += 1;
|
||||||
}
|
}
|
||||||
let inner = &up[start..j.saturating_sub(1)];
|
let inner = &up[start..j.saturating_sub(1)];
|
||||||
out.extend(expand_via(inner));
|
let is_transition = inner.split(|c: char| !c.is_ascii_alphanumeric()).any(|t| {
|
||||||
|
t == "DCT"
|
||||||
|
|| (t.len() >= 2
|
||||||
|
&& t.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
|
||||||
|
&& t.chars().any(|c| c.is_ascii_digit()))
|
||||||
|
});
|
||||||
|
if is_transition {
|
||||||
|
out.extend(expand_via(inner));
|
||||||
|
}
|
||||||
i = j;
|
i = j;
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
|
|||||||
+14
-1
@@ -186,7 +186,20 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => println!("usage: rad-tool sheets | dump | find | summary | fra [point] | caps [FROM TO] | frapts [file] [POINT] | forbidden [ADEP ADES FL] | mandep [ADEP]"),
|
Some("manarr") => {
|
||||||
|
// manarr [ADES] — mandatory arrival transitions (Annex 2B).
|
||||||
|
let rad = flightplanner_rad::parse(&path)?;
|
||||||
|
println!("Mandatory arrival routings (Annex 2B): {}", rad.mandatory_arr.len());
|
||||||
|
if let Some(a) = args.get(2) {
|
||||||
|
let a = a.to_uppercase();
|
||||||
|
let seqs = rad.mandatory_arr_transitions(&a);
|
||||||
|
println!("{a}: {} allowed transition sequences", seqs.len());
|
||||||
|
for s in seqs.iter().take(40) {
|
||||||
|
println!(" {}", s.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => println!("usage: rad-tool sheets | dump | find | summary | fra [point] | caps [FROM TO] | frapts [file] [POINT] | forbidden [ADEP ADES FL] | mandep [ADEP] | manarr [ADES]"),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user