> For the complete documentation index, see [llms.txt](https://threadpoolx.gitbook.io/docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://threadpoolx.gitbook.io/docs/htb-writeups/challenges/not-so-plane.md).

# Not So Plane

### Challenge Information

| Field          | Details                                        |
| -------------- | ---------------------------------------------- |
| **Name**       | Not So Plane                                   |
| **Category**   | Science / Orbital Mechanics                    |
| **Difficulty** | Medium                                         |
| **Tags**       | Python, Astrodynamics, Plane Change, IGSO, API |

***

### Challenge Description

> The satellite is safe in GEO (over the Equator), but the crisis just escalated. Our Cyber Threat Intelligence team discovered that the source of the cyber attack is a mobile ground transmitting station located at a very high latitude. From the Equator, our satellite has no Line of Sight to gather Signal Intelligence (SIGINT). You must execute the most expensive burn possible: a Plane Change. Alter the satellite's inclination and transfer it to an Inclined Geosynchronous Orbit (IGSO) so its ground track forms that characteristic 'figure-eight' and passes directly over the target.

***

### Understanding the Challenge

Two files are provided:

* **`app.py`** — the Flask server running on the target
* **`client.py`** — a skeleton solve script with one stubbed function

#### The API

| Method | Endpoint                 | Purpose                                                        |
| ------ | ------------------------ | -------------------------------------------------------------- |
| GET    | `/api/ideal_geo_orbit`   | Returns the starting GEO orbit (deterministic, no seed)        |
| GET    | `/api/target_igso_orbit` | Returns the target IGSO orbit (seed-based, random per session) |
| POST   | `/api/geo_to_igso`       | Validates your maneuver against IGSO constraints               |
| POST   | `/api/simulate`          | Runs the full simulation and returns the **flag** if valid     |

#### The Full Flow

```
1. Fetch GEO orbit   →  a, e≈0, i≈0, Ω, ω, ν  (starting equatorial orbit)
2. Fetch IGSO orbit  →  a, e≈0, i_target, Ω_target, ω, ν  (target inclined orbit)
3. Compute           →  dt (seconds to burn)  +  dv [x, y, z] (km/s burn vector)
4. Validate          →  server checks resulting orbit meets IGSO constraints
5. Simulate          →  if correct, server returns FLAG
```

#### Server-Side Constraints

From `app.py`:

```python
MAX_REQUESTS     = 10       # seed resets after 10 requests
MAX_DV_ALLOWED   = 3        # km/s sanity cap

# IGSO verification checks:
abs(a - 42164) <= 10        # semi-major axis stays at GEO altitude ± 10 km
e <= 0.001                  # orbit stays circular
abs(t_i - i) <= 0.05        # inclination within 0.05 rad of target
abs(t_Ω - Ω) <= 0.05        # RAAN within 0.05 rad of target
```

#### The Stub Function

```python
def solve_geo_to_igso(geo_orbit, igso_orbit):
    # TODO: Implement this.
    return None, None
```

It must return `(dt, dv_vector)` where:

* `dt` = seconds from simulation start to the burn
* `dv_vector` = numpy array `[dv_x, dv_y, dv_z]` in km/s

***

### Background: What Is an IGSO?

A **GEO (Geostationary Orbit)** is a circular orbit at 42164 km, exactly above the equator (inclination ≈ 0). The satellite appears stationary from the ground but can only see equatorial regions — high-latitude targets are below its horizon.

An **IGSO (Inclined Geosynchronous Orbit)** has the same orbital period and altitude as GEO, but is tilted at an inclination `i`. Because it has the same period as Earth's rotation but a tilted plane, the ground track traces a **figure-eight** (analemma) pattern centred on the equator, reaching the latitude of `±i` degrees. This gives coverage over high-latitude targets that GEO cannot see.

```
              North Pole
                  │
        ┌─────────┼─────────┐
        │    ╱╲   │   ╱╲    │
        │  ╱    ╲ │ ╱    ╲  │
   ─────┼╱───────╲┼╱───────╲┼───── Equator
        │╲       ╱┼╲       ╱│
        │  ╲   ╱  │  ╲   ╱  │
        └─────────┼─────────┘
              Ground track (figure-eight)
```

The plane change burn is known as the **most expensive maneuver** in orbital mechanics — it costs far more propellant than raising or lowering an orbit by the same Δv, because it changes the direction of velocity rather than its magnitude.

***

### Background: The Pure Plane Change

#### What Changes, What Stays the Same

Going from GEO to IGSO:

* **Same** semi-major axis (`a` stays at 42164 km)
* **Same** eccentricity (`e` stays ≈ 0)
* **Same** orbital speed (`v = √(μ/a)` unchanged)
* **Changes** inclination: `0 → i_target`
* **Changes** RAAN: `Ω_geo → Ω_target`

Because only the orbital plane orientation changes, this is called a **pure plane change**.

#### Where Must the Burn Happen?

Two circular orbits — GEO and IGSO — intersect at exactly **two points**: the ascending and descending nodes of the IGSO. These are the only locations where both orbits share the same position in space.

```
  GEO (equatorial plane)
  ─────────●─────────────────────●─────────
           ↑                     ↑
    Ascending node          Descending node
    (longitude = Ω_igso)    (longitude = Ω_igso + π)

  IGSO (inclined plane, crossing equator at these same two points)
```

A burn anywhere other than these intersection points would require simultaneously changing both the velocity direction AND the orbital radius — highly inefficient. The optimal strategy is to burn at one of the nodes.

***

### The Algorithm

#### Part 1 — Time to Burn (dt)

The GEO satellite moves around the equatorial plane. For a circular orbit with inclination ≈ 0, its angular position measured from the vernal equinox (the **true longitude**) evolves as:

```
λ(t) = Ω_geo + ω_geo + ν_geo + n·t

where n = √(μ / a³)   (mean motion, rad/s)
```

The ascending node of the target IGSO lies at true longitude `Ω_igso`. The time for the GEO satellite to reach that point:

```
λ₀       = Ω_geo + ω_geo + ν_geo          (current true longitude)
Δλ       = (Ω_igso − λ₀)  mod  2π         (always positive, range [0, 2π))
dt       = Δλ / n                          (seconds)
```

For the standard case where GEO starts at the origin (`Ω=ω=ν=0`), this simplifies to `dt = Ω_igso / n`.

#### Part 2 — Delta-V Vector (dv)

**Step 1: Velocity vectors at the ascending node**

At the ascending node (position = `r·[cos(Ω), sin(Ω), 0]`):

The **GEO velocity** is purely tangential in the equatorial plane:

```
v_geo = v · [ −sin(Ω),         cos(Ω),        0      ]
```

The **IGSO velocity** at its ascending node is tilted out of the equatorial plane by inclination `i`. This can be derived by placing the perifocal velocity `v·[sin(ω), cos(ω), 0]` at the ascending node (where argument of latitude = 0, so `ν = −ω`) and rotating through the standard Perifocal → ECI matrix:

```
v_igso = v · [ −sin(Ω)·cos(i),  cos(Ω)·cos(i),  sin(i) ]
```

A key result: this expression is **independent of ω** (argument of perigee). For circular orbits, ω cancels out algebraically. This means no matter what value the server sets for `ω_igso`, the velocity direction at the ascending node is always the same.

**Step 2: The Δv vector**

```
dv = v_igso − v_geo

   = v · [ −sin(Ω)·cos(i) − (−sin(Ω)),   cos(Ω)·cos(i) − cos(Ω),   sin(i) ]

   = v · [  sin(Ω)·(1 − cos(i)),          −cos(Ω)·(1 − cos(i)),      sin(i) ]
```

**Step 3: Verify the magnitude**

```
|dv|² = v² · [ sin²(Ω)·(1−cos(i))² + cos²(Ω)·(1−cos(i))² + sin²(i) ]
       = v² · [ (1−cos(i))² + sin²(i) ]
       = v² · [ 1 − 2cos(i) + cos²(i) + sin²(i) ]
       = v² · [ 2 − 2cos(i) ]
       = 4v² · sin²(i/2)

|dv| = 2v · sin(i/2)       ← the classic plane change formula ✓
```

This confirms the formula — the magnitude only depends on the inclination change, not on RAAN.

***

### Solution

#### Full Solve Script

```python
import requests
import numpy as np
from typing import Tuple

MU = 398600.4418          # km³/s²  Earth gravitational parameter
API_BASE_URL = "http://<TARGET_IP>:<PORT>/api"


def solve_geo_to_igso(geo_orbit: Tuple, igso_orbit: Tuple) -> Tuple[float, np.ndarray]:
    """
    Compute time-to-burn (dt, seconds) and delta-v vector (km/s)
    for a pure plane change: GEO (equatorial) → IGSO (inclined).
    All angles from the server are in RADIANS.
    """
    a_geo, e_geo, i_geo, Omega_geo, omega_geo, nu_geo = geo_orbit
    a_igso, e_igso, i_igso, Omega_igso, omega_igso, nu_igso = igso_orbit

    # Circular velocity and mean motion at GEO altitude
    v = np.sqrt(MU / a_geo)       # km/s
    n = np.sqrt(MU / a_geo**3)    # rad/s

    # ── Part 1: Time to ascending node of IGSO ──────────────────────────
    lambda_0    = Omega_geo + omega_geo + nu_geo          # current true longitude
    delta_lambda = (Omega_igso - lambda_0) % (2.0 * np.pi)
    dt = delta_lambda / n

    # ── Part 2: Delta-V vector at ascending node ─────────────────────────
    O = Omega_igso
    i = i_igso

    v_geo_at_node  = v * np.array([-np.sin(O),           np.cos(O),          0.0        ])
    v_igso_at_node = v * np.array([-np.sin(O)*np.cos(i), np.cos(O)*np.cos(i), np.sin(i) ])

    dv = v_igso_at_node - v_geo_at_node
    # Equivalent to: v * [sin(Ω)*(1-cos(i)), -cos(Ω)*(1-cos(i)), sin(i)]

    return dt, dv


def main():
    s = requests.Session()
    base = API_BASE_URL

    # 1. Fetch starting GEO orbit
    print("[*] Fetching ideal GEO orbit...")
    geo_resp = s.get(f"{base}/ideal_geo_orbit").json()
    o = geo_resp["orbit"]
    geo_orbit = (o['a'], o['e'], o['i'], o['Omega'], o['omega'], o['nu'])
    print(f"    a={o['a']:.3f} km,  e={o['e']:.6f},  i={o['i']:.6f} rad")

    # 2. Fetch target IGSO orbit
    print("\n[*] Fetching target IGSO orbit...")
    igso_resp = s.get(f"{base}/target_igso_orbit").json()
    o = igso_resp["orbit"]
    igso_orbit = (o['a'], o['e'], o['i'], o['Omega'], o['omega'], o['nu'])
    print(f"    a={o['a']:.3f} km,  e={o['e']:.6f},  i={o['i']:.6f} rad,  Ω={o['Omega']:.6f} rad")

    # 3. Compute maneuver
    print("\n[*] Computing GEO → IGSO plane change burn...")
    dt, dv = solve_geo_to_igso(geo_orbit, igso_orbit)
    v_geo  = np.sqrt(MU / geo_orbit[0])
    i_igso = igso_orbit[2]
    print(f"    dt (time to burn):  {dt:.2f} s  ({dt/3600:.4f} h)")
    print(f"    Δv magnitude:       {np.linalg.norm(dv):.6f} km/s")
    print(f"    Theory check:       2v·sin(i/2) = {2*v_geo*np.sin(i_igso/2):.6f} km/s")
    print(f"    Δv vector (km/s):   [{dv[0]:.6f}, {dv[1]:.6f}, {dv[2]:.6f}]")

    # 4. Validate maneuver
    print("\n[*] Validating maneuver with server...")
    val_payload = {
        "a": geo_orbit[0], "e": geo_orbit[1], "i": geo_orbit[2],
        "Omega": geo_orbit[3], "omega": geo_orbit[4], "nu": geo_orbit[5],
        "dt": dt, "dv_x": dv[0], "dv_y": dv[1], "dv_z": dv[2]
    }
    val = s.post(f"{base}/geo_to_igso", json=val_payload).json()
    print(f"    Status : {val.get('status')}")
    print(f"    Message: {val.get('message')}")
    if val.get("orbit"):
        r = val["orbit"]
        print(f"    Result: a={r['a']:.3f} km,  e={r['e']:.6f},  i={r['i']:.6f} rad,  Ω={r['Omega']:.6f} rad")

    if not val.get('status'):
        print("\n[-] Validation failed. Exiting.")
        return

    # 5. Simulate and retrieve flag
    print("\n[*] Running simulation...")
    sim = s.post(f"{base}/simulate",
                 json={"dt": dt, "dv_x": dv[0], "dv_y": dv[1], "dv_z": dv[2]}).json()
    print(f"    Status : {sim.get('status')}")
    print(f"    Message: {sim.get('message')}")
    print(f"\n{'='*55}")
    print(f"  FLAG => {sim.get('flag')}")
    print(f"{'='*55}\n")


if __name__ == "__main__":
    main()
```

#### Running the Script

Replace `<TARGET_IP>:<PORT>` with the challenge instance address and run:

```bash
python3 solve.py
```

#### Expected Output

```
[*] Fetching ideal GEO orbit...
    a=42164.000 km,  e=0.000000,  i=0.000000 rad

[*] Fetching target IGSO orbit...
    a=42164.000 km,  e=0.000000,  i=0.xxxxxx rad,  Ω=x.xxxxxx rad

[*] Computing GEO → IGSO plane change burn...
    dt (time to burn):  xxxxx.xx s  (x.xxxx h)
    Δv magnitude:       x.xxxxxx km/s
    Theory check:       2v·sin(i/2) = x.xxxxxx km/s
    Δv vector (km/s):   [x.xxxxxx, x.xxxxxx, x.xxxxxx]

[*] Validating maneuver with server...
    Status : True
    Message: Valid IGSO
    Result: a=42164.xxx km,  e=0.000xxx,  i=x.xxxxxx rad,  Ω=x.xxxxxx rad

[*] Running simulation...
    Status : True
    Message: GEO->IGSO maneuver was successful!

=======================================================
  FLAG => HTB{...}
=======================================================
```

***

### Key Difference from Previous Challenges

This challenge introduces **two** GET requests before the burn, because the GEO start orbit and IGSO target orbit come from different endpoints:

```python
# GEO: deterministic, no seed — same every time
geo_orbit = s.get(f"{base}/ideal_geo_orbit").json()

# IGSO: seed-based, changes after 10 requests
igso_orbit = s.get(f"{base}/target_igso_orbit").json()
```

Since `MAX_REQUESTS = 10` and the script uses **4 requests total** (2× GET + 1× POST validate + 1× POST simulate), you stay well within the window.

Also note that the `/api/simulate` endpoint regenerates both orbits server-side — the payload only needs `dt` and `dv`, not the orbit data.

***

### The ω Independence Proof

A common point of confusion: the target IGSO has a non-zero `ω` (argument of perigee), yet the solution ignores it entirely. Here is why.

For a circular orbit (e = 0) at its ascending node, the argument of latitude is zero by definition: `u = ω + ν = 0`, so `ν = −ω`.

The velocity in the perifocal frame at `ν = −ω`:

```
v_pqw = [ −√(μ/p)·sin(−ω),   √(μ/p)·(0 + cos(−ω)),   0 ]
       = [ v·sin(ω),           v·cos(ω),                  0 ]
```

Applying the Perifocal → ECI rotation matrix `R` (with parameters Ω, i, ω):

```
v_x = v·[ sin(ω)·(cΩ·cω − sΩ·sω·ci) + cos(ω)·(−cΩ·sω − sΩ·cω·ci) ]
     = v·[ cΩ·(sin(ω)·cos(ω) − cos(ω)·sin(ω)) − sΩ·ci·(sin²(ω) + cos²(ω)) ]
     = v·[ 0 − sΩ·ci ]  =  −v·sin(Ω)·cos(i)

v_y = v·[ sin(ω)·(sΩ·cω + cΩ·sω·ci) + cos(ω)·(−sΩ·sω + cΩ·cω·ci) ]
     = v·[ sΩ·(sin(ω)·cos(ω) − cos(ω)·sin(ω)) + cΩ·ci·(sin²(ω) + cos²(ω)) ]
     = v·[ 0 + cΩ·ci ]  =   v·cos(Ω)·cos(i)

v_z = v·[ sin(ω)·si·sω + cos(ω)·si·cω ]
     = v·si·[ sin²(ω) + cos²(ω) ]
     = v·sin(i)
```

The `ω` terms cancel completely in every component. The ascending node velocity for a circular IGSO is always:

```
v_igso_at_node = v · [ −sin(Ω)·cos(i),   cos(Ω)·cos(i),   sin(i) ]
```

This is one of the elegant properties of circular orbits — the argument of perigee is geometrically meaningless, and the math reflects that.

***

### Summary

| Step            | What happened                                                                                                                                                                                  |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Recon**       | Read `app.py` to find two separate GET endpoints (GEO deterministic, IGSO seeded), IGSO validation tolerances (Δi < 0.05 rad, ΔΩ < 0.05 rad), and that simulate regenerates orbits server-side |
| **Key insight** | The plane change burn must happen at the ascending node of the IGSO — the only point shared by both orbits                                                                                     |
| **Part 1 (dt)** | Computed true longitude of GEO satellite, found angular difference to ascending node of IGSO, divided by mean motion                                                                           |
| **Part 2 (dv)** | Subtracted GEO velocity from IGSO velocity at the ascending node; proved the formula is independent of ω                                                                                       |
| **Angle units** | Same as all challenges in this series — angles from the server are in **radians**                                                                                                              |
| **Flag**        | Returned by `/api/simulate` after the orbit passes: `a ≈ 42164 km`, `e < 0.001`, \`                                                                                                            |

***

### Key Takeaways

* A **plane change** is the most Δv-expensive orbital maneuver because it changes the direction of a high-speed velocity vector. For GEO (v ≈ 3.07 km/s) and a 30° inclination change, the cost is about 1.59 km/s — roughly half the speed of a rifle bullet.
* The burn **must** happen at the intersection of the two orbital planes (the nodes). Burning elsewhere wastes Δv on changing orbital shape as well.
* For circular orbits, the argument of perigee `ω` is **mathematically irrelevant** — it cancels out in the velocity calculation at the ascending node. The server includes it in the orbit data, but you can safely ignore it.
* The formula `|Δv| = 2v·sin(Δi/2)` only applies to pure inclination changes (same RAAN). When both inclination and RAAN change simultaneously, the full vector subtraction must be used — which is exactly what this solution does.
* This challenge series (Elementary → Impulsive Thoughts → Not So Plane) builds a complete satellite mission: **launch → orbit insertion → plane change**, each step requiring the correct physics.
