Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 81 additions & 25 deletions internal/engine/baker/meta.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package baker

import (
"path"
"sort"
"strconv"

"github.com/beetlebugorg/chartplotter/pkg/iso8211"
"github.com/beetlebugorg/chartplotter/pkg/s57"
)

// CellMeta is the per-cell metadata extracted at import time for the chart
Expand All @@ -23,49 +27,101 @@ type CellMeta struct {
HasBBox bool `json:"-"`
}

// ExtractCellMeta parses each cell's header + coverage (coverage-only, cheap) and
// returns per-cell metadata keyed by cell stem. Cells that fail to parse are
// reported via onSkip and omitted. Title is left empty (S-57 headers carry no human
// chart name — only the cell code); the caller overlays the CATALOG.031 long name
// where the exchange set provides one.
func ExtractCellMeta(cells map[string]CellData, onSkip func(name string, err error)) map[string]CellMeta {
// ExtractCellMeta returns per-cell metadata keyed by cell stem. Identity and scale
// come from each cell's S-57 header (DSID/DSPM); coverage comes from the exchange
// -set catalogue when it covers the cell — sparing a parse — and otherwise from an
// M_COVR-only coverage parse. Pass cat=nil when there is no catalogue.
//
// Cells that fail to parse are reported via onSkip and omitted. Title is left empty
// (S-57 headers carry no human chart name — only the cell code); the caller overlays
// the CATALOG.031 long name where the exchange set provides one.
func ExtractCellMeta(cells map[string]CellData, cat *s57.Catalog, onSkip func(name string, err error)) map[string]CellMeta {
catBBox := catalogBBoxes(cat)
out := make(map[string]CellMeta, len(cells))
names := make([]string, 0, len(cells))
for n := range cells {
names = append(names, n)
}
sort.Strings(names)
for _, name := range names {
cd := cells[name]
chart, err := ParseCellCoverage(name, cd.Base, cd.Updates)
m, err := cellMetaFor(name, cells[name], catBBox)
if err != nil {
if onSkip != nil {
onSkip(name, err)
}
continue
}
stem := cellStem(chart.DatasetName())
if stem == "" {
stem = cellStem(name)
}
m := CellMeta{
Name: stem,
Scale: int(chart.CompilationScale()),
Edition: chart.Edition(),
Update: chart.UpdateNumber(),
IssueDate: chart.IssueDate(),
Agency: chart.ProducingAgency(),
}
b := chart.Bounds()
if b.MaxLon > b.MinLon && b.MaxLat > b.MinLat {
m.BBox = [4]float64{b.MinLon, b.MinLat, b.MaxLon, b.MaxLat}
m.HasBBox = true
out[m.Name] = m
}
return out
}

// catalogBBoxes indexes an exchange-set catalogue's per-cell coverage by cell stem,
// or returns nil when there's no catalogue / no coverage in it.
func catalogBBoxes(cat *s57.Catalog) map[string][4]float64 {
if cat == nil {
return nil
}
out := map[string][4]float64{}
for _, e := range cat.Cells() {
if e.HasBBox {
out[e.CellStem()] = [4]float64{e.West, e.South, e.East, e.North}
}
out[stem] = m
}
return out
}

// cellMetaFor builds one cell's metadata. When the catalogue already supplies the
// cell's coverage AND the cell has no updates (so its base-cell header still carries
// the current identity), it reads only the header — DSID/DSPM, no geometry — and
// takes the bbox from the catalogue, skipping the M_COVR coverage parse entirely.
// Otherwise it falls back to the coverage parse, which also applies updates so the
// reported edition/update/date reflect the cell's current state.
func cellMetaFor(name string, cd CellData, catBBox map[string][4]float64) (CellMeta, error) {
stem := cellStem(name)
if len(cd.Updates) == 0 {
if box, ok := catBBox[stem]; ok {
p := "/" + path.Base(name)
if h, err := s57.ReadHeaderFS(iso8211.MemFS{p: cd.Base}, p); err == nil {
return CellMeta{
Name: stem,
Scale: int(h.CompilationScale),
Edition: h.Edition,
Update: h.UpdateNumber,
IssueDate: h.IssueDate,
Agency: h.ProducingAgency,
BBox: box,
HasBBox: true,
}, nil
}
// Header read failed (malformed front matter) — fall through to a full parse.
}
}

chart, err := ParseCellCoverage(name, cd.Base, cd.Updates)
if err != nil {
return CellMeta{}, err
}
s := cellStem(chart.DatasetName())
if s == "" {
s = stem
}
m := CellMeta{
Name: s,
Scale: int(chart.CompilationScale()),
Edition: chart.Edition(),
Update: chart.UpdateNumber(),
IssueDate: chart.IssueDate(),
Agency: chart.ProducingAgency(),
}
b := chart.Bounds()
if b.MaxLon > b.MinLon && b.MaxLat > b.MinLat {
m.BBox = [4]float64{b.MinLon, b.MinLat, b.MaxLon, b.MaxLat}
m.HasBBox = true
}
return m, nil
}

// cellStem trims a trailing ".000"/".NNN" or directory path from a cell name.
func cellStem(name string) string {
// Strip any directory.
Expand Down
45 changes: 44 additions & 1 deletion internal/engine/baker/meta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ package baker
import (
"os"
"testing"

"github.com/beetlebugorg/chartplotter/pkg/s57"
)

func TestExtractCellMeta(t *testing.T) {
data, err := os.ReadFile("../../../testdata/US5MD1MC.000")
if err != nil {
t.Fatal(err)
}
meta := ExtractCellMeta(map[string]CellData{"US5MD1MC.000": {Base: data}}, nil)
meta := ExtractCellMeta(map[string]CellData{"US5MD1MC.000": {Base: data}}, nil, nil)
m, ok := meta["US5MD1MC"]
if !ok {
t.Fatalf("no metadata for US5MD1MC; got keys %v", keys(meta))
Expand All @@ -33,6 +35,47 @@ func TestExtractCellMeta(t *testing.T) {
}
}

// TestExtractCellMeta_CatalogFastPath proves the catalogue short-circuit: when the
// exchange-set catalogue already carries a (base) cell's coverage, identity is read
// from the cheap header and the bbox is taken verbatim from the catalogue — no
// M_COVR coverage parse. The stored bbox being the catalogue's exact rectangle (not
// the geometry-derived M_COVR extent) is what confirms the fast path engaged.
func TestExtractCellMeta_CatalogFastPath(t *testing.T) {
data, err := os.ReadFile("../../../testdata/US5MD1MC.000")
if err != nil {
t.Fatal(err)
}
catData, err := os.ReadFile("../../../pkg/s57/testdata/US5MD1MC_CATALOG.031")
if err != nil {
t.Fatal(err)
}
cat, err := s57.ParseCatalog(catData)
if err != nil {
t.Fatal(err)
}
var catBox [4]float64
for _, e := range cat.Cells() {
if e.CellStem() == "US5MD1MC" && e.HasBBox {
catBox = [4]float64{e.West, e.South, e.East, e.North}
}
}
if catBox == ([4]float64{}) {
t.Fatal("catalogue fixture lacks US5MD1MC coverage")
}

meta := ExtractCellMeta(map[string]CellData{"US5MD1MC.000": {Base: data}}, cat, nil)
m, ok := meta["US5MD1MC"]
if !ok {
t.Fatalf("no metadata for US5MD1MC; got %v", keys(meta))
}
if m.Scale != 12000 || m.Agency != 550 {
t.Errorf("identity = scale %d agency %d, want 12000 / 550", m.Scale, m.Agency)
}
if !m.HasBBox || m.BBox != catBox {
t.Errorf("BBox = %v (has=%v), want catalogue box %v verbatim", m.BBox, m.HasBBox, catBox)
}
}

func keys(m map[string]CellMeta) []string {
out := make([]string, 0, len(m))
for k := range m {
Expand Down
38 changes: 31 additions & 7 deletions internal/engine/server/cellindex.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,23 @@ import (
"sync"

"github.com/beetlebugorg/chartplotter/internal/engine/baker"
"github.com/beetlebugorg/chartplotter/pkg/s57"
)

// boundsUsable reports whether a parsed cell yielded a non-degenerate bbox (a real
// extent, not the empty/zero box of a cell whose coverage couldn't be derived).
func boundsUsable(b s57.Bounds) bool {
return b.MaxLon > b.MinLon && b.MaxLat > b.MinLat
}

// cellIndex is a small, persistent name→bounding-box index over the cached source
// cells (<dataDir>/ENC_ROOT/<CELL>/<CELL>.000). It lets the server answer "where
// is cell X" and "which installed cells are active" without re-parsing thousands
// of cells on every request: each cell's header is read ONCE (the bbox cached to
// <dataDir>/cells-index.json), then queries hit the in-memory map. Kept
// deliberately simple — a flat JSON map, not a database; the data is tiny (a few
// floats per cell) and read-mostly.
// of cells on every request: each cell is parsed ONCE — only its M_COVR coverage,
// not the whole cell (see scan) — with the bbox cached to <dataDir>/cells-index
// .json, then queries hit the in-memory map. Kept deliberately simple — a flat
// JSON map, not a database; the data is tiny (a few floats per cell) and
// read-mostly.
type cellIndex struct {
mu sync.RWMutex
cond *sync.Cond // broadcast when a scan finishes (for wait())
Expand Down Expand Up @@ -126,8 +134,12 @@ func (ci *cellIndex) wait() {
ci.mu.Unlock()
}

// scan reads every cached cell's header once (bbox cached so repeat scans skip the
// already-indexed) and reconciles: drops index entries for cells no longer on disk.
// scan derives every cached cell's bbox once (cached, so repeat scans skip the
// already-indexed) and reconciles: drops index entries for cells no longer on
// disk. The bbox comes from an M_COVR-only coverage parse — the cell's data
// coverage is all we need, so we skip building the geometry, R-tree and portrayal
// of every other feature that a full parse would. A cell with no M_COVR (rare:
// synthetic/test cells) falls back to a full parse so it still gets a bbox.
func (ci *cellIndex) scan() {
entries, err := os.ReadDir(ci.encRoot)
if err != nil {
Expand All @@ -148,11 +160,23 @@ func (ci *cellIndex) scan() {
if err != nil {
continue
}
chart, err := baker.ParseCellBytes(name, data)
// M_COVR-only parse: builds just the coverage rings, not every feature's
// geometry — all the bbox needs. nil updates: the index tracks base cells.
chart, err := baker.ParseCellCoverage(name, data, nil)
if err != nil {
continue
}
b := chart.Bounds()
if !boundsUsable(b) {
// No M_COVR coverage polygon (rare — synthetic cells omit it). Fall back to
// a full parse so the cell still lands in the index with a real bbox.
if full, ferr := baker.ParseCellBytes(name, data); ferr == nil {
b = full.Bounds()
}
}
if !boundsUsable(b) {
continue // still nothing usable; skip rather than index a degenerate box
}
ci.mu.Lock()
ci.bbox[name] = [4]float64{b.MinLon, b.MinLat, b.MaxLon, b.MaxLat}
ci.mu.Unlock()
Expand Down
2 changes: 1 addition & 1 deletion internal/engine/server/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,7 @@ func (s *Server) bakeAndRegister(jobID, set string, cells map[string]baker.CellD
// agency/coverage (cheap coverage-only parse) overlaid with the catalogue's chart
// titles + coverage. Best-effort — a write failure only costs the extracted detail.
s.imports.update(jobID, func(j *importJob) { j.Phase, j.Note = "meta", "Reading chart metadata" })
cellMeta := baker.ExtractCellMeta(cells, func(name string, e error) {
cellMeta := baker.ExtractCellMeta(cells, cat, func(name string, e error) {
log.Printf("import %s: meta skip %s: %v", jobID, name, e)
})
meta := buildSetMeta(set, cellMeta, cat)
Expand Down
4 changes: 2 additions & 2 deletions internal/engine/server/import_meta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func TestImport_NoCatalog(t *testing.T) {
if set := s.deriveUploadSet(cat, cells); set != "user-us5md1mc" {
t.Errorf("deriveUploadSet = %q, want user-us5md1mc", set)
}
meta := buildSetMeta("user-us5md1mc", baker.ExtractCellMeta(cells, nil), cat)
meta := buildSetMeta("user-us5md1mc", baker.ExtractCellMeta(cells, cat, nil), cat)
if meta.ScaleMin != 12000 || len(meta.BBox) != 4 || meta.Agency != "NOAA (US)" {
t.Errorf("header metadata missing: scale=%d bbox=%v agency=%q", meta.ScaleMin, meta.BBox, meta.Agency)
}
Expand Down Expand Up @@ -115,7 +115,7 @@ func TestImport_AutoNameAndMeta(t *testing.T) {
}

// The post-bake metadata tail (bakeAndRegister does exactly this after baking).
cellMeta := baker.ExtractCellMeta(cells, nil)
cellMeta := baker.ExtractCellMeta(cells, cat, nil)
meta := buildSetMeta(set, cellMeta, cat)
meta.Imported = "2026-06-25T00:00:00Z"
if err := s.writeSetMeta(set, meta); err != nil {
Expand Down
87 changes: 87 additions & 0 deletions internal/s57/parser/header.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package parser

import (
"fmt"
"io"
"io/fs"

"github.com/beetlebugorg/chartplotter/pkg/iso8211"
)

// CellHeader is the lightweight identity + compilation scale of an S-57 cell,
// read from only its leading DSID/DSPM records — no feature or spatial records,
// no geometry. It is the cheap answer when a caller needs to know WHAT a cell is
// (and at what scale/band) without portraying it.
//
// Note: S-57 stores NO bounding box in the header. A cell's geographic extent
// comes from its M_COVR coverage features (or the exchange-set catalogue's CATD
// bbox), neither of which is read here. Use the catalogue or an M_COVR-only parse
// for bounds.
type CellHeader struct {
DatasetName string // DSID DSNM — cell code, e.g. "US5MD1MC"
Edition string // DSID EDTN
UpdateNumber string // DSID UPDN ("0" for a base cell)
IssueDate string // DSID ISDT (YYYYMMDD)
ProducingAgency int // DSID AGEN — IHO agency code (550 = NOAA)
CompilationScale int32 // DSPM CSCL — scale denominator (0 if no DSPM)
}

// ReadHeaderFS reads only a cell's leading dataset-metadata records (DSID + DSPM)
// from fsys, stopping as soon as both are seen — or when the metadata block ends
// (the first feature/spatial record) — without ever reading the feature or
// spatial records. This is dramatically cheaper than Parse when only identity and
// scale are needed (e.g. bucketing cells by band, or filling in metadata whose
// bounds come from elsewhere). Updates are NOT applied: the result reflects the
// base cell as given.
func ReadHeaderFS(fsys fs.FS, filename string) (*CellHeader, error) {
p, err := iso8211.OpenFS(fsys, filename)
if err != nil {
return nil, err
}
defer p.Close()
return readHeader(p)
}

func readHeader(p *iso8211.Parser) (*CellHeader, error) {
h := &CellHeader{}
var gotDSID, gotDSPM bool
// DSID and DSPM live in the dataset general-information / geographic-reference
// records at the very front of the file, before any feature (FRID) or spatial
// (VRID) record. Read records one at a time until both are in hand, or until the
// metadata block is over — so a cell that omits DSPM doesn't drag us through the
// whole file.
for !(gotDSID && gotDSPM) {
rec, err := p.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if d, ok := rec.Fields["DSID"]; ok && !gotDSID {
m := parseDSID(d)
h.DatasetName = m.dsnm
h.Edition = m.edtn
h.UpdateNumber = m.updn
h.IssueDate = m.isdt
h.ProducingAgency = m.agen
gotDSID = true
}
if d, ok := rec.Fields["DSPM"]; ok && !gotDSPM {
h.CompilationScale = parseDSPM(d).CSCL
gotDSPM = true
}
// First feature/spatial record ⇒ the metadata block has ended; nothing more
// to find. Stop rather than scan the rest of the cell.
if _, ok := rec.Fields["FRID"]; ok {
break
}
if _, ok := rec.Fields["VRID"]; ok {
break
}
}
if !gotDSID {
return nil, fmt.Errorf("no DSID record in cell header")
}
return h, nil
}
Loading