feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
package solarclosure
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
var testSubsolar = geodata.GeoPoint{Longitude: 12, Latitude: 34}
|
||||
|
||||
func testTerminator() []geodata.GeoPoint {
|
||||
return Terminator(testSubsolar)
|
||||
}
|
||||
|
||||
// testFootprint 取地平圈上一段采样点当开放边界,两端擦地点各外扩两个采样点。
|
||||
func testFootprint() Footprint {
|
||||
circle := testTerminator()
|
||||
return Footprint{
|
||||
Boundaries: [][]geodata.GeoPoint{append([]geodata.GeoPoint(nil), circle[100:131]...)},
|
||||
HorizonEnds: []geodata.GeoPoint{circle[98], circle[133]},
|
||||
Subsolar: testSubsolar,
|
||||
}
|
||||
}
|
||||
|
||||
func angleFromSubsolar(point geodata.GeoPoint) float64 {
|
||||
cosine := math.Sin(testSubsolar.Latitude*math.Pi/180)*math.Sin(point.Latitude*math.Pi/180) +
|
||||
math.Cos(testSubsolar.Latitude*math.Pi/180)*math.Cos(point.Latitude*math.Pi/180)*
|
||||
math.Cos((point.Longitude-testSubsolar.Longitude)*math.Pi/180)
|
||||
return math.Acos(math.Max(-1, math.Min(1, cosine))) * 180 / math.Pi
|
||||
}
|
||||
|
||||
func TestTerminatorPointsStayOnHorizonCircle(t *testing.T) {
|
||||
circle := testTerminator()
|
||||
if len(circle) != 360 {
|
||||
t.Fatalf("terminator points=%d, want 360", len(circle))
|
||||
}
|
||||
for index, point := range circle {
|
||||
if angle := angleFromSubsolar(point); math.Abs(angle-90) > 1e-9 {
|
||||
t.Fatalf("point %d is %.12f degrees from the subsolar point, want 90", index, angle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHorizonEndsPairsBothEndsByBoundaryStart(t *testing.T) {
|
||||
circle := testTerminator()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
ends []geodata.GeoPoint
|
||||
want []geodata.GeoPoint
|
||||
}{
|
||||
{name: "in order", ends: []geodata.GeoPoint{circle[98], circle[133]},
|
||||
want: []geodata.GeoPoint{circle[98], circle[133]}},
|
||||
{name: "reversed", ends: []geodata.GeoPoint{circle[133], circle[98]},
|
||||
want: []geodata.GeoPoint{circle[98], circle[133]}},
|
||||
} {
|
||||
footprint := testFootprint()
|
||||
footprint.HorizonEnds = test.ends
|
||||
got := HorizonEnds(footprint)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("%s: ends=%d, want 2", test.name, len(got))
|
||||
}
|
||||
for index := range got {
|
||||
if got[index] != test.want[index] {
|
||||
t.Fatalf("%s: ends[%d]=%v, want %v", test.name, index, got[index], test.want[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHorizonEndsRejectsIncompleteInput(t *testing.T) {
|
||||
circle := testTerminator()
|
||||
base := testFootprint()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
footprint Footprint
|
||||
}{
|
||||
{name: "no ends", footprint: Footprint{Boundaries: base.Boundaries}},
|
||||
{name: "one end", footprint: Footprint{Boundaries: base.Boundaries,
|
||||
HorizonEnds: []geodata.GeoPoint{circle[98]}}},
|
||||
{name: "three ends", footprint: Footprint{Boundaries: base.Boundaries,
|
||||
HorizonEnds: []geodata.GeoPoint{circle[98], circle[133], circle[134]}}},
|
||||
{name: "no boundaries", footprint: Footprint{HorizonEnds: base.HorizonEnds}},
|
||||
{name: "empty first segment", footprint: Footprint{
|
||||
Boundaries: [][]geodata.GeoPoint{{}}, HorizonEnds: base.HorizonEnds}},
|
||||
} {
|
||||
if ends := HorizonEnds(test.footprint); ends != nil {
|
||||
t.Fatalf("%s: ends=%v, want nil", test.name, ends)
|
||||
}
|
||||
if ExactHorizon(test.footprint) {
|
||||
t.Fatalf("%s: ExactHorizon=true, want false", test.name)
|
||||
}
|
||||
}
|
||||
if !ExactHorizon(base) {
|
||||
t.Fatal("ExactHorizon=false for a footprint carrying two grazing points")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRingClosesOpenBoundaryAtGrazingPoints(t *testing.T) {
|
||||
footprint := testFootprint()
|
||||
curve := Curve(footprint)
|
||||
if len(curve) != 31 {
|
||||
t.Fatalf("curve points=%d, want 31", len(curve))
|
||||
}
|
||||
ends := HorizonEnds(footprint)
|
||||
ring, boundary, ok := Ring(footprint, true)
|
||||
if !ok {
|
||||
t.Fatal("Ring reported an unusable boundary")
|
||||
}
|
||||
if len(ring) <= len(curve) {
|
||||
t.Fatalf("ring points=%d, want more than the %d boundary points", len(ring), len(curve))
|
||||
}
|
||||
for index, point := range curve {
|
||||
if ring[index] != point {
|
||||
t.Fatalf("ring[%d]=%v, want the boundary point %v", index, ring[index], point)
|
||||
}
|
||||
}
|
||||
if ring[len(curve)] != ends[1] {
|
||||
t.Fatalf("ring[%d]=%v, want the trailing grazing point %v", len(curve), ring[len(curve)], ends[1])
|
||||
}
|
||||
if ring[len(ring)-1] != ends[0] {
|
||||
t.Fatalf("ring ends at %v, want the leading grazing point %v", ring[len(ring)-1], ends[0])
|
||||
}
|
||||
if len(boundary) != len(curve)+2 || boundary[0] != ends[0] ||
|
||||
boundary[len(boundary)-1] != ends[1] {
|
||||
t.Fatalf("boundary=%d points starting %v ending %v", len(boundary), boundary[0],
|
||||
boundary[len(boundary)-1])
|
||||
}
|
||||
for index, point := range boundary[1 : len(boundary)-1] {
|
||||
if point != curve[index] {
|
||||
t.Fatalf("boundary[%d]=%v, want %v", index+1, point, curve[index])
|
||||
}
|
||||
}
|
||||
for index, point := range ring {
|
||||
if angle := angleFromSubsolar(point); math.Abs(angle-90) > 1e-9 {
|
||||
t.Fatalf("ring[%d] is %.12f degrees from the subsolar point, want 90", index, angle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRingFallsBackToSampledTerminatorArc(t *testing.T) {
|
||||
footprint := testFootprint()
|
||||
curve := Curve(footprint)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
input Footprint
|
||||
exact bool
|
||||
}{
|
||||
{name: "no grazing points", input: Footprint{
|
||||
Boundaries: footprint.Boundaries, Subsolar: testSubsolar}, exact: true},
|
||||
{name: "approximate requested", input: footprint, exact: false},
|
||||
} {
|
||||
ring, boundary, ok := Ring(test.input, test.exact)
|
||||
if !ok {
|
||||
t.Fatalf("%s: Ring reported an unusable boundary", test.name)
|
||||
}
|
||||
if len(ring) <= len(curve) || ring[len(ring)-1] != curve[0] {
|
||||
t.Fatalf("%s: ring closes at %v, want the boundary start %v",
|
||||
test.name, ring[len(ring)-1], curve[0])
|
||||
}
|
||||
for index, point := range curve {
|
||||
if ring[index] != point {
|
||||
t.Fatalf("%s: ring[%d]=%v, want %v", test.name, index, ring[index], point)
|
||||
}
|
||||
}
|
||||
if len(boundary) != len(curve) {
|
||||
t.Fatalf("%s: boundary points=%d, want the %d boundary points", test.name, len(boundary), len(curve))
|
||||
}
|
||||
for index, point := range ring {
|
||||
if angle := angleFromSubsolar(point); math.Abs(angle-90) > 1e-9 {
|
||||
t.Fatalf("%s: ring[%d] is %.12f degrees from the subsolar point, want 90",
|
||||
test.name, index, angle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRingDegenerateInputs(t *testing.T) {
|
||||
circle := testTerminator()
|
||||
closed := []geodata.GeoPoint{circle[10], circle[40], circle[70], circle[10]}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
footprint Footprint
|
||||
wantOK bool
|
||||
wantRing int
|
||||
}{
|
||||
{name: "no boundary", footprint: Footprint{Closed: true}, wantOK: false},
|
||||
{name: "closed triangle", footprint: Footprint{Boundaries: [][]geodata.GeoPoint{
|
||||
{circle[10], circle[40], circle[70]}}, Closed: true}, wantOK: true, wantRing: 3},
|
||||
{name: "closed with repeated point", footprint: Footprint{
|
||||
Boundaries: [][]geodata.GeoPoint{closed}, Closed: true}, wantOK: true, wantRing: 3},
|
||||
{name: "closed segment", footprint: Footprint{Boundaries: [][]geodata.GeoPoint{
|
||||
{circle[10], circle[40]}}, Closed: true}, wantOK: false},
|
||||
{name: "single open point", footprint: Footprint{Boundaries: [][]geodata.GeoPoint{
|
||||
{circle[10]}}}, wantOK: true, wantRing: 1},
|
||||
{name: "open segment without grazing points", footprint: Footprint{
|
||||
Boundaries: [][]geodata.GeoPoint{{circle[10], circle[40]}},
|
||||
Subsolar: testSubsolar}, wantOK: true},
|
||||
} {
|
||||
ring, _, ok := Ring(test.footprint, true)
|
||||
if ok != test.wantOK {
|
||||
t.Fatalf("%s: ok=%v, want %v", test.name, ok, test.wantOK)
|
||||
}
|
||||
if test.wantRing > 0 && len(ring) != test.wantRing {
|
||||
t.Fatalf("%s: ring points=%d, want %d", test.name, len(ring), test.wantRing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHorizonRingAcceptsPrejoinedCurve(t *testing.T) {
|
||||
footprint := testFootprint()
|
||||
curve := Curve(footprint)
|
||||
ring, boundary := HorizonRing(footprint, curve)
|
||||
ends := HorizonEnds(footprint)
|
||||
if len(boundary) != len(curve)+2 || boundary[0] != ends[0] ||
|
||||
boundary[len(boundary)-1] != ends[1] {
|
||||
t.Fatalf("boundary=%d points, want the leading and trailing grazing points around %d points",
|
||||
len(boundary), len(curve))
|
||||
}
|
||||
if len(ring) < len(curve)+2 || ring[len(curve)] != ends[1] || ring[len(ring)-1] != ends[0] {
|
||||
t.Fatalf("ring=%d points, want the grazing points closing %d boundary points",
|
||||
len(ring), len(curve))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBandPolygonsSelectsFaceCoveredByFootprint(t *testing.T) {
|
||||
box := [][2]geodata.GeoPoint{
|
||||
{{Longitude: 0, Latitude: 0}, {Longitude: 10, Latitude: 0}},
|
||||
{{Longitude: 10, Latitude: 0}, {Longitude: 10, Latitude: 10}},
|
||||
{{Longitude: 10, Latitude: 10}, {Longitude: 0, Latitude: 10}},
|
||||
{{Longitude: 0, Latitude: 10}, {Longitude: 0, Latitude: 0}},
|
||||
}
|
||||
contours := make([][]geodata.GeoPoint, 0, len(box))
|
||||
for _, edge := range box {
|
||||
contours = append(contours, []geodata.GeoPoint{edge[0], edge[1]})
|
||||
}
|
||||
inside := []geodata.GeoPoint{
|
||||
{Longitude: 4, Latitude: 4}, {Longitude: 6, Latitude: 4},
|
||||
{Longitude: 6, Latitude: 6}, {Longitude: 4, Latitude: 6},
|
||||
}
|
||||
footprints := []Footprint{{Boundaries: [][]geodata.GeoPoint{inside}, Closed: true}}
|
||||
polygons, ok := BandPolygons(contours, nil, footprints, true, SnapDistanceKM)
|
||||
if !ok || len(polygons) == 0 {
|
||||
t.Fatalf("band polygons unavailable: ok=%v count=%d", ok, len(polygons))
|
||||
}
|
||||
probes := []geodata.GeoPoint{{Longitude: 5, Latitude: 5}, {Longitude: 1, Latitude: 9}}
|
||||
contained := geodata.SphericalPolygonsContainPoints(polygons, probes)
|
||||
if len(contained) != 2 || !contained[0] || !contained[1] {
|
||||
t.Fatalf("containment=%v, want the whole box face selected", contained)
|
||||
}
|
||||
outside := geodata.SphericalPolygonsContainPoints(polygons,
|
||||
[]geodata.GeoPoint{{Longitude: 15, Latitude: 5}})
|
||||
if outside[0] {
|
||||
t.Fatal("a point outside the boundary network was selected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBandPolygonsRejectsEmptyBoundaryNetwork(t *testing.T) {
|
||||
if polygons, ok := BandPolygons(nil, nil, nil, true, SnapDistanceKM); ok || polygons != nil {
|
||||
t.Fatalf("polygons=%d ok=%v, want no polygons", len(polygons), ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Package solarclosure 把被地平线切断的日食偏食足迹闭合到地平圈,并把零食分包络与
|
||||
// 日升日落相位线并集成一块可见域。
|
||||
//
|
||||
// Package solarclosure closes horizon-cut solar-eclipse footprints onto the horizon and
|
||||
// unions the zero-magnitude envelope with the rise/set phase lines into one visibility region.
|
||||
package solarclosure
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
// Footprint 是一个瞬时足迹的闭合输入。
|
||||
type Footprint struct {
|
||||
// Boundaries 是物理边界分段;反经线或无效投影会拆成多段。
|
||||
Boundaries [][]geodata.GeoPoint
|
||||
// HorizonEnds 是未闭合边界两端的地平擦地点,顺序任意,使用时按 Boundaries 走向排序。
|
||||
HorizonEnds []geodata.GeoPoint
|
||||
// Subsolar 是该时刻的太阳直射点,缺少精确擦地点时用它采样地平圈近似补口。
|
||||
Subsolar geodata.GeoPoint
|
||||
// Closed 表示 Boundaries 自身闭合,不需要补口。
|
||||
Closed bool
|
||||
}
|
||||
|
||||
// Terminator 返回以太阳直射点为圆心的地平圈采样点。
|
||||
func Terminator(subsolar geodata.GeoPoint) []geodata.GeoPoint {
|
||||
return geodata.SphericalCircle(subsolar, 90, 360)
|
||||
}
|
||||
|
||||
// HorizonEnds 按 Boundaries 走向排序两个地平擦地点;点数不是 2 或首段为空时返回 nil。
|
||||
func HorizonEnds(footprint Footprint) []geodata.GeoPoint {
|
||||
if len(footprint.HorizonEnds) != 2 || len(footprint.Boundaries) == 0 ||
|
||||
len(footprint.Boundaries[0]) == 0 {
|
||||
return nil
|
||||
}
|
||||
points := []geodata.GeoPoint{footprint.HorizonEnds[0], footprint.HorizonEnds[1]}
|
||||
if pointDistanceKM(points[0], footprint.Boundaries[0][0]) >
|
||||
pointDistanceKM(points[1], footprint.Boundaries[0][0]) {
|
||||
points[0], points[1] = points[1], points[0]
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
// ExactHorizon 报告足迹能否用两个精确擦地点闭合。
|
||||
func ExactHorizon(footprint Footprint) bool {
|
||||
return len(HorizonEnds(footprint)) == 2
|
||||
}
|
||||
|
||||
// Curve 返回足迹的物理边界折线,重复的闭合点已去掉。
|
||||
func Curve(footprint Footprint) []geodata.GeoPoint {
|
||||
return openRing(geodata.JoinPolylineSegments(footprint.Boundaries))
|
||||
}
|
||||
|
||||
// Ring 返回足迹的填充环与补口后的物理边界折线;ok 为假表示边界点不足以成环。
|
||||
// exact 为假或缺少擦地点时按 Subsolar 地平圈的最短弧近似补口。
|
||||
func Ring(footprint Footprint, exact bool) (ring, boundary []geodata.GeoPoint, ok bool) {
|
||||
curve := Curve(footprint)
|
||||
// 单点开放边界既不能补口也不该报错:调用方按退化区域丢弃。
|
||||
if len(curve) == 1 && !footprint.Closed {
|
||||
return curve, curve, true
|
||||
}
|
||||
minimumPoints := 3
|
||||
if !footprint.Closed {
|
||||
minimumPoints = 2
|
||||
}
|
||||
if len(curve) < minimumPoints {
|
||||
return nil, nil, false
|
||||
}
|
||||
if footprint.Closed {
|
||||
return append([]geodata.GeoPoint(nil), curve...), curve, true
|
||||
}
|
||||
ring, boundary = closeOpen(footprint, curve, exact)
|
||||
if len(openRing(ring)) < 3 {
|
||||
return nil, nil, false
|
||||
}
|
||||
return ring, boundary, true
|
||||
}
|
||||
|
||||
// HorizonRing 补出已拼接的开放边界 curve 的填充环与物理边界折线,不做点数校验。
|
||||
func HorizonRing(footprint Footprint, curve []geodata.GeoPoint) (ring, boundary []geodata.GeoPoint) {
|
||||
return closeOpen(footprint, curve, true)
|
||||
}
|
||||
|
||||
// SnapDistanceKM 是并集线网的节点吸附尺度:比这更近的交点按同一个物理节点处理。
|
||||
const SnapDistanceKM = 25
|
||||
|
||||
// BandPolygons 以零食分连续包络和日升日落相位线为线网、瞬时足迹为覆盖面,
|
||||
// 返回偏食可见域的并集;ok 为假表示线网无法成面。
|
||||
// exact 为真时瞬时足迹按精确擦地点补口,为假时按 Subsolar 地平圈近似补口。
|
||||
func BandPolygons(
|
||||
contours, phaseLines [][]geodata.GeoPoint,
|
||||
footprints []Footprint,
|
||||
exact bool,
|
||||
snapDistanceKM float64,
|
||||
) ([][]geodata.GeoPoint, bool) {
|
||||
boundaryLines := make([][]geodata.GeoPoint, 0, len(contours)+len(phaseLines))
|
||||
boundaryLines = append(boundaryLines, contours...)
|
||||
boundaryLines = append(boundaryLines, phaseLines...)
|
||||
fillPolygons := make([][]geodata.GeoPoint, 0, len(footprints))
|
||||
coveragePaths := make([][]geodata.GeoPoint, 0, len(footprints)*2)
|
||||
for _, footprint := range footprints {
|
||||
for _, segment := range footprint.Boundaries {
|
||||
if len(segment) > 0 {
|
||||
coveragePaths = append(coveragePaths, segment)
|
||||
}
|
||||
}
|
||||
if ring, _, ok := Ring(footprint, exact); ok && len(ring) >= 3 {
|
||||
fillPolygons = append(fillPolygons, ring)
|
||||
}
|
||||
}
|
||||
polygons, err := geodata.VisibleLineworkPolygons(
|
||||
boundaryLines, fillPolygons, coveragePaths, snapDistanceKM,
|
||||
)
|
||||
if err != nil || len(polygons) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
for polygonIndex := range polygons {
|
||||
for pointIndex := range polygons[polygonIndex] {
|
||||
polygons[polygonIndex][pointIndex].Longitude =
|
||||
normalizeLongitude(polygons[polygonIndex][pointIndex].Longitude)
|
||||
}
|
||||
}
|
||||
return polygons, true
|
||||
}
|
||||
|
||||
// closeOpen 用精确擦地点闭合开放边界;擦地点缺失或 exact 为假时改用 Subsolar 地平圈的近似弧。
|
||||
func closeOpen(footprint Footprint, curve []geodata.GeoPoint, exact bool) ([]geodata.GeoPoint, []geodata.GeoPoint) {
|
||||
if len(curve) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ends := []geodata.GeoPoint(nil)
|
||||
if exact {
|
||||
ends = HorizonEnds(footprint)
|
||||
}
|
||||
if len(ends) != 2 {
|
||||
ring := append([]geodata.GeoPoint(nil), curve...)
|
||||
arc := geodata.ShortestCircleArc(Terminator(footprint.Subsolar), curve[len(curve)-1], curve[0])
|
||||
if len(arc) > 1 {
|
||||
ring = append(ring, arc[1:]...)
|
||||
}
|
||||
return ring, curve
|
||||
}
|
||||
boundary := make([]geodata.GeoPoint, 0, len(curve)+2)
|
||||
boundary = append(boundary, ends[0])
|
||||
boundary = append(boundary, curve...)
|
||||
boundary = append(boundary, ends[1])
|
||||
ring := make([]geodata.GeoPoint, 0, len(curve)+3)
|
||||
ring = append(ring, curve...)
|
||||
ring = append(ring, ends[1])
|
||||
arc := geodata.ShortestCircleArc(Terminator(footprint.Subsolar), ends[1], ends[0])
|
||||
if len(arc) > 1 {
|
||||
ring = append(ring, arc[1:]...)
|
||||
}
|
||||
return ring, boundary
|
||||
}
|
||||
|
||||
func openRing(points []geodata.GeoPoint) []geodata.GeoPoint {
|
||||
if len(points) > 1 && geodata.SameGeoPoint(points[0], points[len(points)-1]) {
|
||||
return points[:len(points)-1]
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
func normalizeLongitude(value float64) float64 {
|
||||
value = math.Mod(value+180, 360)
|
||||
if value < 0 {
|
||||
value += 360
|
||||
}
|
||||
return value - 180
|
||||
}
|
||||
|
||||
func pointDistanceKM(first, second geodata.GeoPoint) float64 {
|
||||
lat1, lat2 := first.Latitude*math.Pi/180, second.Latitude*math.Pi/180
|
||||
dlat := lat2 - lat1
|
||||
dlon := math.Remainder((second.Longitude-first.Longitude)*math.Pi/180, 2*math.Pi)
|
||||
h := math.Sin(dlat/2)*math.Sin(dlat/2) + math.Cos(lat1)*math.Cos(lat2)*math.Sin(dlon/2)*math.Sin(dlon/2)
|
||||
return 6371.0088 * 2 * math.Asin(math.Sqrt(math.Max(0, math.Min(1, h))))
|
||||
}
|
||||
Reference in New Issue
Block a user