> 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/elementary.md).

# Elementary

### Challenge Information

| Field          | Details                     |
| -------------- | --------------------------- |
| **Name**       | Elementary                  |
| **Category**   | Science / Orbital Mechanics |
| **Difficulty** | Easy                        |
| **Tags**       | Python, Astrodynamics, API  |

***

### Challenge Description

> You are a Flight Dynamics Engineer (FDE) at the mission control center for a critical new satellite deployment. The launch vehicle has just released the satellite into space, but shortly after separation, a silent zero-day malware strikes the Ground Segment, crippling all automated navigation systems. To save the multi-million-dollar asset, you are forced to fall back to a legacy, manual Web Interface — the Emergency Backup System. Every maneuver must now be calculated and executed by hand.
>
> The space agency's ground radars are feeding us the satellite's position in the form of Keplerian Elements. The problem? The navigation computer strictly requires Cartesian State Vectors (Position and Velocity coordinates in X, Y, Z). Feed the interface the correct coordinates.

***

### Understanding the Challenge

Before writing any code, we need to understand what we are dealing with. The challenge gives us two files:

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

#### The API

The server exposes three endpoints:

| Method | Endpoint                   | Purpose                                                                   |
| ------ | -------------------------- | ------------------------------------------------------------------------- |
| GET    | `/api/target_gto_orbit`    | Returns the satellite's orbit as Keplerian elements                       |
| POST   | `/api/kepler_to_cartesian` | Validates your Cartesian conversion (pos err < 5 km, vel err < 0.01 km/s) |
| POST   | `/api/simulate`            | Runs a 5-hour orbital simulation and returns the **flag**                 |

#### The Flow

```
1. Fetch orbit  →  a, e, i, Ω, ω, ν  (Keplerian elements)
2. Convert      →  [rx, ry, rz], [vx, vy, vz]  (Cartesian ECI vectors)
3. Validate     →  server checks your position/velocity error
4. Simulate     →  if correct, returns the FLAG
```

#### The Constraints

Looking at `app.py`:

```python
MAX_REQUESTS     = 10          # state resets after 10 requests (new random seed)
MAX_POS_ERROR_KM = 5.0         # position error must be < 5 km
MAX_VEL_ERROR_KMS = 0.01       # velocity error must be < 0.01 km/s

GEO_APOGEE_KM  = 42164.0      # apogee must reach GEO
LEO_PERIGEE_KM = 6678.137     # perigee must start from LEO
if i >= 1:                     # inclination must be < 1 (radians!)
    return False, "Inclination requirement failed"
```

The orbit is always a **GTO (Geostationary Transfer Orbit)**: an ellipse with perigee at \~6678 km and apogee at \~42164 km from Earth's center.

***

### The Stub Function

Inside `client.py`, the core conversion is left as a TODO:

```python
def kepler_to_cartesian(a, e, i, Omega, omega, nu):
    # TODO: Implement this.
    return None, None
```

This is the only thing we need to implement. Everything else — fetching, validating, simulating — is already wired up.

***

### Background: Keplerian Elements

A satellite's orbit is fully described by 6 numbers called **Keplerian elements**:

| Symbol | Name                | Description                                              |
| ------ | ------------------- | -------------------------------------------------------- |
| `a`    | Semi-major axis     | The "size" of the orbit (km)                             |
| `e`    | Eccentricity        | How elliptical (0 = circle, <1 = ellipse)                |
| `i`    | Inclination         | Tilt of orbital plane relative to equator (rad)          |
| `Ω`    | RAAN                | Rotation of the plane around Earth's polar axis (rad)    |
| `ω`    | Argument of Perigee | Angle to the closest point from the ascending node (rad) |
| `ν`    | True Anomaly        | Where the satellite currently is along the orbit (rad)   |

The **ECI (Earth-Centered Inertial)** frame is a 3D Cartesian coordinate system centred on Earth where X points toward the vernal equinox, Z points toward the North Pole, and Y completes the right-hand system.

***

### The Algorithm: Keplerian → Cartesian (ECI)

The conversion is a standard astrodynamics procedure and works in two stages.

#### Stage 1 — Perifocal Frame (PQW)

The **perifocal frame** is the orbit's own 2D coordinate system. The P-axis points toward perigee, Q-axis is 90° ahead in the orbit, and W-axis is perpendicular to the orbital plane.

Using the **orbit equation**:

```
p = a × (1 − e²)          ← semi-latus rectum
r = p / (1 + e × cos(ν))  ← orbital radius at true anomaly ν
```

Position and velocity in the perifocal frame:

```
r_PQW = [ r × cos(ν),                  r × sin(ν),                0 ]
v_PQW = [ −√(μ/p) × sin(ν),   √(μ/p) × (e + cos(ν)),            0 ]
```

where `μ = 398600.4418 km³/s²` is Earth's standard gravitational parameter.

#### Stage 2 — Rotate to ECI

The three angles `Ω`, `i`, `ω` define how the orbital plane is oriented in 3D space. We apply the classic **3-1-3 Euler rotation**:

```
R = Rz(−Ω) · Rx(−i) · Rz(−ω)
```

The combined rotation matrix is:

```
R = | cos(Ω)cos(ω) − sin(Ω)sin(ω)cos(i)    −cos(Ω)sin(ω) − sin(Ω)cos(ω)cos(i)    sin(Ω)sin(i) |
    | sin(Ω)cos(ω) + cos(Ω)sin(ω)cos(i)    −sin(Ω)sin(ω) + cos(Ω)cos(ω)cos(i)   −cos(Ω)sin(i) |
    | sin(i)sin(ω)                           sin(i)cos(ω)                           cos(i)       |
```

Then:

```
r_ECI = R · r_PQW
v_ECI = R · v_PQW
```

***

### Critical Observation: Angles Are in Radians

> ⚠️ **This is the key gotcha in this challenge.**

When running the script for the first time without any conversion fix, you may see:

```
[*] Validating conversion with server...
    Status : False
    Message: Position Error too high: 5935.62 km.
```

That is a massive error — far too large to be floating point noise. The clue is in the API response values:

```
ω = 5.4574
```

Is `5.4574` degrees or radians? Let's check:

* `5.4574` degrees → tiny angle, basically points along the X axis
* `5.4574` radians → `≈ 312.7°`, a completely different direction

The `app.py` code confirms it with this check:

```python
if i >= 1:
    return False, "Inclination requirement failed: must be < 1 degree."
```

Despite the misleading error message saying "1 degree", the condition is `i >= 1` — which is `1 radian` (≈ 57.3°), a valid constraint for a near-equatorial GTO orbit. The server works entirely in **radians**.

If you mistakenly apply `np.radians()` to the values, you shrink `ω = 5.4574 rad` down to `0.0953 rad` and rotate to the completely wrong position, producing exactly the observed \~5935 km error.

**The fix: do not convert. The angles are already in radians.**

***

### Solution

#### Full Solve Script

```python
import requests
import numpy as np
from typing import Tuple, List, Dict, Any

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


def kepler_to_cartesian(a, e, i, Omega, omega, nu) -> Tuple[List[float], List[float]]:
    """
    Convert Keplerian orbital elements to Cartesian ECI state vectors.
    All angles (i, Omega, omega, nu) are in RADIANS as returned by the server.
    """
    # Angles are already in radians — do NOT call np.radians()

    # Semi-latus rectum and orbital radius
    p = a * (1.0 - e**2)
    r = p / (1.0 + e * np.cos(nu))

    # Position and velocity in Perifocal (PQW) frame
    r_pqw = np.array([r * np.cos(nu), r * np.sin(nu), 0.0])

    sqrt_mu_p = np.sqrt(MU / p)
    v_pqw = np.array([
        -sqrt_mu_p * np.sin(nu),
         sqrt_mu_p * (e + np.cos(nu)),
        0.0
    ])

    # Rotation matrix: Perifocal → ECI   R = Rz(−Ω) · Rx(−i) · Rz(−ω)
    cO, sO = np.cos(Omega), np.sin(Omega)
    ci, si = np.cos(i),     np.sin(i)
    co, so = np.cos(omega), np.sin(omega)

    R = np.array([
        [ cO*co - sO*so*ci,  -cO*so - sO*co*ci,  sO*si ],
        [ sO*co + cO*so*ci,  -sO*so + cO*co*ci, -cO*si ],
        [ si*so,               si*co,              ci   ]
    ])

    # Rotate perifocal vectors into ECI
    r_eci = R @ r_pqw
    v_eci = R @ v_pqw

    return r_eci.tolist(), v_eci.tolist()


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

    # Step 1: Fetch target orbit
    print("[*] Fetching target GTO orbit...")
    orbit_data = s.get(f"{base}/target_gto_orbit").json().get("orbit", {})
    a, e, i     = orbit_data['a'],     orbit_data['e'],     orbit_data['i']
    Omega, omega, nu = orbit_data['Omega'], orbit_data['omega'], orbit_data['nu']
    print(f"    a={a:.3f} km,  e={e:.6f}")
    print(f"    i={i:.6f} rad,  Ω={Omega:.6f} rad,  ω={omega:.6f} rad,  ν={nu:.6f} rad")

    # Step 2: Convert to Cartesian
    print("\n[*] Computing Cartesian state vectors...")
    pos, vel = kepler_to_cartesian(a, e, i, Omega, omega, nu)
    print(f"    Position (km):   rx={pos[0]:.4f},  ry={pos[1]:.4f},  rz={pos[2]:.4f}")
    print(f"    Velocity (km/s): vx={vel[0]:.6f},  vy={vel[1]:.6f},  vz={vel[2]:.6f}")

    payload = {
        **orbit_data,
        "pos_x": pos[0], "pos_y": pos[1], "pos_z": pos[2],
        "vel_x": vel[0], "vel_y": vel[1], "vel_z": vel[2]
    }

    # Step 3: Validate with server
    print("\n[*] Validating conversion with server...")
    val = s.post(f"{base}/kepler_to_cartesian", json=payload).json()
    print(f"    Status : {val.get('status')}")
    print(f"    Message: {val.get('message')}")

    if not val.get('status'):
        print("\n[-] Validation failed. Try running again (server may have reset seed).")
        return

    # Step 4: Simulate and get flag
    print("\n[*] Running orbital simulation...")
    sim = s.post(f"{base}/simulate", json=payload).json()
    print(f"    Status : {sim.get('status')}")
    print(f"    Message: {sim.get('message')}")
    print(f"\n{'='*50}")
    print(f"  FLAG => {sim.get('flag')}")
    print(f"{'='*50}\n")


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

#### Running the Script

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

```bash
python3 solve.py
```

#### Expected Output

```
[*] Fetching target GTO orbit...
    a=24421.069 km,  e=0.726542
    i=0.000000 rad,  Ω=0.000000 rad,  ω=5.457400 rad,  ν=0.000000 rad

[*] Computing Cartesian state vectors...
    Position (km):   rx=4533.xxxx,  ry=-4901.xxxx,  rz=0.0000
    Velocity (km/s): vx=7.xxxxxx,  vy=7.xxxxxx,  vz=0.000000

[*] Validating conversion with server...
    Status : True
    Message: Trajectory verified successfully.

[*] Running orbital simulation...
    Status : True
    Message: Conversion was successful!

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

***

### Summary

| Step          | What happened                                                                                  |
| ------------- | ---------------------------------------------------------------------------------------------- |
| **Recon**     | Read `app.py` to understand the 3-endpoint API flow and validation tolerances                  |
| **Core task** | Implement `kepler_to_cartesian()` using the standard Perifocal → ECI conversion                |
| **Gotcha**    | Server returns angles in **radians** — applying `np.radians()` produces a \~5935 km error      |
| **Fix**       | Use angles as-is from the API response; no unit conversion needed                              |
| **Flag**      | Returned by `/api/simulate` after passing position error < 5 km and velocity error < 0.01 km/s |

***

### Key Takeaways

* Always inspect what unit system an API uses before assuming degrees. The error message in the server code saying "must be < 1 degree" was deliberately misleading — the actual check is `i >= 1` in radians.
* The Keplerian → Cartesian conversion is a fully deterministic, closed-form formula. No iteration or numerical methods needed.
* The server resets its random seed every 10 requests, so keep your solve under 10 API calls. This script uses exactly **3** (GET + 2× POST).
