Skip to content

Commit 440120f

Browse files
committed
oco: spectra explorer shows all three bands at once; footprint view explains the slit rotation
The 12 km view looked odd because it is: OCO-2 rotates its slit along the orbit (~85 deg from the track at the equator, ~12 deg over Los Angeles), so the eight footprints of a frame lie nearly along the track there. The widget now draws one frame explicitly with numbered footprints and states the slit angle; measured quantities are renamed along-slit / along-track (1.08 x 2.28 km, 8.7 km end to end) and the README documents the finding. Spectra explorer: three stacked panels (fixed axes 0-300 / 0-50 / 0-22) instead of tabs.
1 parent da7e159 commit 440120f

6 files changed

Lines changed: 131 additions & 108 deletions

File tree

‎oco/data/README.md‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,21 @@ polarization**, so the L1b radiance is ≈ ½ of the total intensity of an unpol
4545
| file | contents | check |
4646
|---|---|---|
4747
| `track_oco2_orbit.json` (6 kB) | orbit 53380, 2024-07-14, nadir; sub-satellite point every 10 s | 280 points, 20:39:55–21:26:25 UTC, lon −176.6…176.4 (crosses the dateline), lat −58.3…81.8 |
48-
| `footprints_oco2_la.json` (71 kB) | 100 consecutive nadir frames over the LA basin (centre 34.125 N, 117.898 W), 8 footprints × 4 O₂-band vertices | 755 of 800 footprints have vertices (45 are fill values in the L1b file → `null`); **medians: cross-track 1.085 km, along-track 2.276 km, swath 8.67 km** |
48+
| `footprints_oco2_la.json` (72 kB) | 100 consecutive nadir frames over the LA basin (centre 34.125 N, 117.898 W), 8 footprints × 4 O₂-band vertices | 755 of 800 footprints have vertices (45 are fill values in the L1b file → `null`); **medians: 1.085 km along the slit × 2.276 km along track, 8.67 km for the eight footprints end to end; slit 11.7° from the ground track** |
4949
| `footprints_oco3_sam_la.json` (209 kB) | OCO-3 SAM `fossil0005` "fossil_Los_Angeles_USA", 2022-02-18, all Lite soundings grouped by frame and swath | 305 frames, 2199 footprints, 8 swaths, 1641 good-quality, median XCO₂ 419.4 ppm; medians 2.25 × 2.34 km |
5050
| `basemaps.json` + `img/basemap_{orbit,200km,12km,12km_oco3}.jpg` | stitched OpenStreetMap standard raster tiles (Web Mercator), bounds in the JSON | orbit z2 1024×800 (lon −300…60), 200 km z9 1100×760 (253 m/px), 12 km z13 1100×760 (15.8 m/px); © OpenStreetMap contributors (ODbL) |
5151

52-
The footprint sizes are geodesic side lengths of the L1b vertex parallelograms (short pair =
53-
across track, long pair = along track); swath = separation of footprint 1 and 8 centroids + one
54-
footprint width. **They come out at 1.09 / 2.28 / 8.7 km, not the often-quoted 1.29 / 2.25 /
55-
10.3 km** — the along-track size agrees, the across-track width from the vertices is ~15 %
56-
smaller; reported as measured, not adjusted. The OCO-3 file carries only the soundings that
57-
passed L2 pre-screening (the Lite product), not every L1b sounding of the SAM.
52+
**Geometry finding (2026-09-16).** OCO-2 does not keep its slit across the ground track: the
53+
slit direction rotates along the orbit (measured on three granules: ~85° from the track near the
54+
equator, ~70° at 28°S, only ~10° at 34°N, ~35° at 62°N), so over Los Angeles the eight footprints of
55+
a frame are stacked nearly **along** the track and the 8.7 km "swath" lies along the slit. Footprint
56+
dimensions are geodesic side lengths of the L1b vertex parallelograms: the short side is the pitch
57+
along the slit (1.09 km, nominal 1.29 km), the long side the along-track smear per ⅓-s frame
58+
(2.28 km, nominal 2.25 km); each footprint is only ~0.6 km wide across the slit. The along-slit
59+
values are ~15 % below the often-quoted 1.29 / 10.3 km; reported as measured, not adjusted. The
60+
widget draws one frame explicitly with its eight numbered footprints and states the slit angle. The
61+
OCO-3 file carries only the soundings that passed L2 pre-screening (the Lite product), not every L1b
62+
sounding of the SAM.
5863

5964
## 3. Surface albedo
6065

‎oco/data/footprints_oco2_la.json‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎oco/scripts/make_footprints_json.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,14 @@ def poly_dims(vlo, vla):
3737
# vertices ordered around the footprint: sides 0-1, 1-2, 2-3, 3-0; along/cross from the two side pairs
3838
s = [g.inv(vlo[i], vla[i], vlo[(i+1) % 4], vla[(i+1) % 4])[2] / 1e3 for i in range(4)]
3939
return sorted([np.mean([s[0], s[2]]), np.mean([s[1], s[3]])]) # (short, long)
40-
short, long_, swath = [], [], []
40+
short, long_, swath, slit_ang = [], [], [], []
41+
def ang_diff(a, b): return (a - b + 90) % 180 - 90 # signed angle between two lines, -90..90
4142
fr = []
4243
for f in frames:
44+
# slit direction (fp1 -> fp8 centres) relative to the ground-track heading (footprint 4, this frame -> next)
45+
if f + 1 < lat.shape[0] and np.all(np.isfinite([lat[f, 0], lat[f, 7], lat[f + 1, 3]])):
46+
az_t = g.inv(lon[f, 3], lat[f, 3], lon[f + 1, 3], lat[f + 1, 3])[0]; az_s = g.inv(lon[f, 0], lat[f, 0], lon[f, 7], lat[f, 7])[0]
47+
slit_ang.append(ang_diff(az_s, az_t))
4348
fps = []
4449
for k in range(8):
4550
vlo, vla = vlon[f, k, :], vlat[f, k, :]
@@ -53,10 +58,12 @@ def poly_dims(vlo, vla):
5358
fr.append({"t": hms(t[f, 3]), "fp": fps})
5459
meta = {"file": os.path.basename(l1b), "date": date, "orbit": orbit, "mode": mode, "n_frames": NF, "n_footprints": int(sum(fp is not None for x in fr for fp in x["fp"])),
5560
"frame_rate_hz": 3, "centre_frame_lat": r5(nadir_lat[c]), "centre_frame_lon": r5(nadir_lon[c]),
56-
"crosstrack_km_median": r5(np.nanmedian(short)), "alongtrack_km_median": r5(np.nanmedian(long_)), "swath_km_median": r5(np.nanmedian(swath)),
61+
"footprint_along_slit_km_median": r5(np.nanmedian(short)), "footprint_along_track_km_median": r5(np.nanmedian(long_)), "swath_along_slit_km_median": r5(np.nanmedian(swath)),
62+
"slit_angle_from_track_deg_median": r5(np.median(slit_ang)) if slit_ang else None,
63+
"geometry_note": "OCO-2 rotates its slit along the orbit (about 85 deg from the track near the equator, only ~10 deg from it over Los Angeles at 34 N), so here the eight footprints of a frame are stacked nearly ALONG the ground track and the 'swath' lies along the slit, not across the track. Footprint dims are geodesic side lengths of the L1b vertex parallelograms: short side = pitch along the slit (nominal 1.29 km), long side = along-track smear per 1/3 s frame (nominal 2.25 km).",
5764
"vertices": "O2 A-band footprint vertices from L1b FootprintGeometry (geodetic, topography-corrected), [lon, lat]",
5865
"created": datetime.date.today().isoformat(),
59-
"missing_note": "footprints with fill-value vertices in the L1b file are null (see n_footprints)", "description": f"{NF} consecutive nadir frames over ({lat0}, {lon0}), window chosen with the fewest fill-value footprints; footprint dims are geodesic side lengths (short = across track, long = along track); swath = outer footprint centroid separation + one footprint width"}
66+
"missing_note": "footprints with fill-value vertices in the L1b file are null (see n_footprints)", "description": f"{NF} consecutive nadir frames over ({lat0}, {lon0}), window chosen with the fewest fill-value footprints; swath = separation of footprint 1 and 8 centroids + one footprint pitch"}
6067
json.dump({"meta": meta, "frames": fr}, open(f"{out}/footprints_oco2_la.json", "w"), separators=(",", ":"))
6168
print("track:", len(idx), "points", track["meta"]["time_utc_first"], "->", track["meta"]["time_utc_last"], f"{os.path.getsize(out+'/track_oco2_orbit.json')/1e3:.0f} kB")
62-
print("footprints:", meta["n_footprints"], "footprints, centre", meta["centre_frame_lat"], meta["centre_frame_lon"], "medians cross/along/swath km:", meta["crosstrack_km_median"], meta["alongtrack_km_median"], meta["swath_km_median"], f"{os.path.getsize(out+'/footprints_oco2_la.json')/1e3:.0f} kB")
69+
print("footprints:", meta["n_footprints"], "footprints, centre", meta["centre_frame_lat"], meta["centre_frame_lon"], "medians along-slit/along-track/swath km:", meta["footprint_along_slit_km_median"], meta["footprint_along_track_km_median"], meta["swath_along_slit_km_median"], "slit angle from track", meta["slit_angle_from_track_deg_median"], f"{os.path.getsize(out+'/footprints_oco2_la.json')/1e3:.0f} kB")

‎oco/widgets/HEIGHTS.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ keep them closed on the Caltech page or add ~150 px. No widget reported a consol
88

99
| widget | 890 px | 890 px, no caption | 650 px | 650 px, no caption | suggested iframe height (no caption) |
1010
|---|---|---|---|---|---|
11-
| oco/widgets/spectra-explorer.html | 726 | 609 | 881 | 763 | 620 (890) / 780 (650) |
12-
| oco/widgets/footprint-scale.html | 703 | 587 | 723 | 607 | 600 / 620 |
11+
| oco/widgets/spectra-explorer.html (three stacked bands, 2026-09-16 rev.) | 865 | 795 | 987 | 929 | 810 (890) / 945 (650) |
12+
| oco/widgets/footprint-scale.html (2026-09-16 rev., longer readouts) | ~740 | 621 | ~745 | 621 | 635 / 635 |
1313
| oco/widgets/photon-path.html | 672 | 535 | 750 | 613 | 550 / 630 |
1414
| sif/widgets/fraunhofer-explorer.html (`?data=/sif/data/solar_757_771nm.json`) | 691 | 603 | 719 | 631 | 620 / 650 |
1515
| sif/widgets/sif-map.html | 717 | 558 | 629 | 470 | 570 / 480 |

‎oco/widgets/footprint-scale.html‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
</div>
3636
<div class="body"><canvas id="cv" aria-label="Map of OCO footprints at the selected scale"></canvas></div>
3737
<div class="readouts" id="ro"></div>
38-
<p class="caption" id="cap">Each OCO-2 sounding is a parallelogram about 1 km across and 2 km along the orbit; eight sit side by side across a swath only ~9 km wide, three frames every second. A single 99-minute orbit traces one thin line around the Earth, so the map is built from thousands of such lines over sixteen days. OCO-3 on the Space Station instead sweeps its pointing mirror to lay adjacent swaths over a city, a "snapshot area map".</p>
38+
<p class="caption" id="cap">Each OCO-2 sounding is a sliver about 1 km along the spectrometer slit and 2 km along the orbit (the smear during one third of a second); eight sit end to end along the slit, only ~9 km in total, three frames every second. The satellite rotates its slit along the orbit, so over Los Angeles the eight footprints happen to line up almost along the track instead of across it. A single 99-minute orbit traces one thin line around the Earth, so global maps are built from thousands of such lines over sixteen days. OCO-3 on the Space Station instead sweeps its pointing mirror to lay adjacent swaths over a city, a "snapshot area map".</p>
3939
<p class="attrib" id="attrib">Basemap © OpenStreetMap contributors</p>
4040
</div>
4141
<script>
@@ -85,10 +85,23 @@
8585
if (TRACK){ ctx.strokeStyle=C.track; ctx.lineWidth=state.z==="orbit"?2.2:6; ctx.globalAlpha=state.z==="orbit"?1:0.25; ctx.beginPath(); let pen=false, px0=null;
8686
for (const [lo,la] of TRACK.lonlat){ if (la<view.lat_s || la>view.lat_n){ pen=false; continue; } // outside the basemap: lift the pen
8787
const x=P.x(lo), y=P.y(la); if (pen && Math.abs(x-px0)>P.w/2) pen=false; pen?ctx.lineTo(x,y):ctx.moveTo(x,y); pen=true; px0=x; } ctx.stroke(); ctx.globalAlpha=1; }
88-
if (FP2){ let n=0; for (const f of FP2.frames) for (const fp of f.fp){ if(!fp) continue; n++; ctx.beginPath(); fp.forEach(([lo,la],i)=> i?ctx.lineTo(P.x(lo),P.y(la)):ctx.moveTo(P.x(lo),P.y(la))); ctx.closePath();
89-
ctx.fillStyle=C.fpFill; ctx.fill(); if (state.z==="12km"){ ctx.strokeStyle=C.fp; ctx.lineWidth=1; ctx.stroke(); } }
90-
const m=FP2.meta; ro.push(["footprint", `${m.crosstrack_km_median.toFixed(2)} × ${m.alongtrack_km_median.toFixed(2)} km (across × along track, medians of ${m.n_footprints} vertices sets)`]);
91-
ro.push(["swath", `${m.swath_km_median.toFixed(1)} km, 8 footprints`]); ro.push(["shown", `${m.n_frames} frames = ${(m.n_frames/m.frame_rate_hz).toFixed(0)} s of flight, ${m.date} ${FP2.frames[0].t.slice(0,8)} UTC, orbit ${m.orbit}`]);
88+
if (FP2){
89+
const poly=(fp)=>{ ctx.beginPath(); fp.forEach(([lo,la],i)=> i?ctx.lineTo(P.x(lo),P.y(la)):ctx.moveTo(P.x(lo),P.y(la))); ctx.closePath(); };
90+
// all frames, light fill (at 12 km only the frames inside the view matter)
91+
for (const f of FP2.frames) for (const fp of f.fp){ if(!fp) continue; poly(fp); ctx.fillStyle=C.fpFill; ctx.globalAlpha=state.z==="12km"?0.35:1; ctx.fill(); ctx.globalAlpha=1; }
92+
if (state.z==="12km"){
93+
// ONE frame drawn explicitly: the frame whose centroid is nearest the map centre, with its eight footprints outlined and numbered
94+
const cx=(view.lon_w+view.lon_e)/2, cy=(view.lat_s+view.lat_n)/2; let best=null, bd=1e9;
95+
for (const f of FP2.frames){ const c=centreOf([f]); const d=Math.hypot((c[0]-cx)*Math.cos(cy*Math.PI/180), c[1]-cy); if (d<bd){ bd=d; best=f; } }
96+
if (best){ best.fp.forEach((fp,k)=>{ if(!fp) return; poly(fp); ctx.fillStyle=C.fpFill; ctx.fill(); ctx.strokeStyle=C.fp; ctx.lineWidth=1.6; ctx.stroke();
97+
const c=centreOf([{fp:[fp]}]); ctx.fillStyle=C.fp; ctx.font="bold 11px ui-monospace, Menlo, monospace"; ctx.textAlign="center"; ctx.textBaseline="middle"; ctx.fillText(String(k+1), P.x(c[0]), P.y(c[1])); });
98+
const l1=`one frame (⅓ s), footprints 1–8, ${best.t.slice(0,8)} UTC`, l2=`slit ${Math.abs(FP2.meta.slit_angle_from_track_deg_median).toFixed(0)}° from the ground track here`;
99+
ctx.font="11px ui-monospace, Menlo, monospace"; const bw=Math.max(ctx.measureText(l1).width, ctx.measureText(l2).width)+14;
100+
ctx.fillStyle="rgba(255,255,255,0.88)"; ctx.fillRect(P.ox+P.w-bw-8,P.oy+8,bw,34); ctx.fillStyle="#1d2530"; ctx.textAlign="left"; ctx.textBaseline="top";
101+
ctx.fillText(l1, P.ox+P.w-bw-1, P.oy+12); ctx.fillText(l2, P.ox+P.w-bw-1, P.oy+26); }
102+
}
103+
const m=FP2.meta; ro.push(["footprint", `${m.footprint_along_slit_km_median.toFixed(2)} km along the slit × ${m.footprint_along_track_km_median.toFixed(2)} km along track (medians of ${m.n_footprints} vertex sets)`]);
104+
ro.push(["8 footprints", `${m.swath_along_slit_km_median.toFixed(1)} km end to end along the slit, which is rotated ${Math.abs(m.slit_angle_from_track_deg_median).toFixed(0)}° from the ground track over LA (≈ 85° near the equator)`]); ro.push(["shown", `${m.n_frames} frames = ${(m.n_frames/m.frame_rate_hz).toFixed(0)} s of flight, ${m.date} ${FP2.frames[0].t.slice(0,8)} UTC, orbit ${m.orbit}`]);
92105
if (TRACK) ro.push(["orbit track", `${TRACK.meta.n_points} points, 10 s apart, ${TRACK.meta.date} (daytime half of one orbit)`]); }
93106
} else if (FP3){
94107
for (const f of FP3.frames) for (const fp of f.fp){ if(!fp) continue; ctx.beginPath(); fp.forEach(([lo,la],i)=> i?ctx.lineTo(P.x(lo),P.y(la)):ctx.moveTo(P.x(lo),P.y(la))); ctx.closePath(); ctx.fillStyle=C.fp2Fill; ctx.fill(); if (state.z==="12km"){ ctx.strokeStyle=C.fp2; ctx.lineWidth=1; ctx.stroke(); } }

0 commit comments

Comments
 (0)