···11+(** CCSDS Key-Value Notation (KVN) parser.
22+33+ {b Reference}: CCSDS 502.0-B-3 Annex A *)
44+55+(* {1 Line classification} *)
66+77+type line =
88+ | Keyword of string * string
99+ | Data of string
1010+ | Comment of string
1111+ | Blank
1212+1313+let classify s =
1414+ let s = String.trim s in
1515+ if String.length s = 0 || s.[0] = '#' then Blank
1616+ else if String.length s >= 7 && String.sub s 0 7 = "COMMENT" then
1717+ Comment (String.trim (String.sub s 7 (String.length s - 7)))
1818+ else
1919+ match String.index_opt s '=' with
2020+ | Some i ->
2121+ let key = String.trim (String.sub s 0 i) in
2222+ let value =
2323+ String.trim (String.sub s (i + 1) (String.length s - i - 1))
2424+ in
2525+ Keyword (key, value)
2626+ | None ->
2727+ (* Check for block delimiters (no = sign): META_START, TRAJ_STOP, etc. *)
2828+ if
2929+ String.length s > 4
3030+ &&
3131+ let suffix = String.sub s (String.length s - 5) 5 in
3232+ suffix = "START" || suffix = "_STOP"
3333+ then Keyword (s, "")
3434+ else Data s
3535+3636+(* {1 Epoch parsing} *)
3737+3838+let parse_epoch s =
3939+ let s = String.trim s in
4040+ let rfc =
4141+ if String.contains s 'Z' || String.contains s '+' then s else s ^ "Z"
4242+ in
4343+ match Ptime.of_rfc3339 ~strict:false rfc with
4444+ | Ok (t, _, _) -> Some t
4545+ | Error _ -> None
4646+4747+(* {1 Line-by-line parser} *)
4848+4949+type state = { lines : (int * string) array; mutable pos : int }
5050+5151+let of_lines lines = { lines; pos = 0 }
5252+5353+let of_string s =
5454+ let lines = String.split_on_char '\n' s in
5555+ let numbered = List.mapi (fun i l -> (i + 1, l)) lines |> Array.of_list in
5656+ of_lines numbered
5757+5858+let of_channel ic =
5959+ let lines = ref [] in
6060+ let n = ref 0 in
6161+ (try
6262+ while true do
6363+ incr n;
6464+ lines := (!n, input_line ic) :: !lines
6565+ done
6666+ with End_of_file -> ());
6767+ of_lines (Array.of_list (List.rev !lines))
6868+6969+let peek st =
7070+ if st.pos >= Array.length st.lines then None else Some st.lines.(st.pos)
7171+7272+let advance st = if st.pos < Array.length st.lines then st.pos <- st.pos + 1
7373+let eof st = st.pos >= Array.length st.lines
7474+7575+let next st =
7676+ match peek st with
7777+ | None -> None
7878+ | Some l ->
7979+ advance st;
8080+ Some l
8181+8282+let skip_blanks st =
8383+ let cont = ref true in
8484+ while !cont do
8585+ match peek st with
8686+ | Some (_, s) -> (
8787+ match classify s with
8888+ | Blank | Comment _ -> advance st
8989+ | _ -> cont := false)
9090+ | None -> cont := false
9191+ done
9292+9393+(* {1 Block iteration} *)
9494+9595+let iter_block ~start ~stop st ~on_keyword ~on_data =
9696+ skip_blanks st;
9797+ (* Skip the start delimiter if present *)
9898+ (match peek st with
9999+ | Some (_, s) when String.trim s = start -> advance st
100100+ | _ -> ());
101101+ let cont = ref true in
102102+ while !cont do
103103+ match peek st with
104104+ | Some (n, s) -> (
105105+ let trimmed = String.trim s in
106106+ if trimmed = stop then begin
107107+ advance st;
108108+ cont := false
109109+ end
110110+ else
111111+ match classify s with
112112+ | Keyword (k, v) ->
113113+ on_keyword k v;
114114+ advance st
115115+ | Data d ->
116116+ on_data n d;
117117+ advance st
118118+ | Comment _ | Blank -> advance st)
119119+ | None -> cont := false
120120+ done
121121+122122+let expect_keyword st key =
123123+ skip_blanks st;
124124+ match peek st with
125125+ | Some (_, s) -> (
126126+ match classify s with
127127+ | Keyword (k, v) when k = key ->
128128+ advance st;
129129+ Some v
130130+ | _ -> None)
131131+ | None -> None
132132+133133+(* {1 Value parsing} *)
134134+135135+let parse_float s =
136136+ match float_of_string_opt (String.trim s) with
137137+ | Some f -> Some f
138138+ | None -> None
139139+140140+let parse_int s =
141141+ match int_of_string_opt (String.trim s) with Some i -> Some i | None -> None
142142+143143+let split_data s =
144144+ String.split_on_char ' ' s
145145+ |> List.filter (fun s -> String.length (String.trim s) > 0)
146146+ |> List.map String.trim
147147+148148+let parse_floats s =
149149+ split_data s
150150+ |> List.map (fun s ->
151151+ match float_of_string_opt s with Some f -> f | None -> 0.0)
152152+ |> Array.of_list
153153+154154+(* {1 Pretty-printing} *)
155155+156156+let pp_line ppf = function
157157+ | Keyword (k, v) -> Fmt.pf ppf "%s = %s" k v
158158+ | Data d -> Fmt.pf ppf "DATA: %s" d
159159+ | Comment c -> Fmt.pf ppf "COMMENT %s" c
160160+ | Blank -> Fmt.pf ppf "(blank)"
+89
lib/kvn.mli
···11+(** CCSDS Key-Value Notation (KVN) parser.
22+33+ KVN is the text format used by CCSDS navigation data messages: OEM, OCM,
44+ TDM, ADM, CDM, and others. This library provides low-level parsing
55+ primitives and block iteration.
66+77+ {b Reference}: CCSDS 502.0-B-3 Annex A (KVN format definition) *)
88+99+(** {1 Line classification} *)
1010+1111+type line =
1212+ | Keyword of string * string (** [KEY = VALUE] *)
1313+ | Data of string (** Epoch + numeric data (no [=] sign) *)
1414+ | Comment of string (** [COMMENT ...] *)
1515+ | Blank (** Empty line or whitespace *)
1616+1717+val classify : string -> line
1818+(** [classify s] classifies a KVN line. *)
1919+2020+(** {1 Epoch parsing} *)
2121+2222+val parse_epoch : string -> Ptime.t option
2323+(** [parse_epoch s] parses a CCSDS epoch string to [Ptime.t]. Accepts
2424+ ["YYYY-MM-DDThh:mm:ss.sss"] and space-separated variants. *)
2525+2626+(** {1 Line-by-line parser} *)
2727+2828+type state
2929+(** Mutable parser state over numbered lines. *)
3030+3131+val of_string : string -> state
3232+(** [of_string s] creates a parser from a KVN string. *)
3333+3434+val of_channel : in_channel -> state
3535+(** [of_channel ic] creates a parser from a channel. *)
3636+3737+val peek : state -> (int * string) option
3838+(** [peek st] returns the current line (number, text) without advancing. *)
3939+4040+val advance : state -> unit
4141+(** [advance st] moves to the next line. *)
4242+4343+val next : state -> (int * string) option
4444+(** [next st] returns the current line and advances. *)
4545+4646+val skip_blanks : state -> unit
4747+(** [skip_blanks st] skips blank and comment lines. *)
4848+4949+val eof : state -> bool
5050+(** [eof st] is [true] if all lines have been consumed. *)
5151+5252+(** {1 Block iteration} *)
5353+5454+val iter_block :
5555+ start:string ->
5656+ stop:string ->
5757+ state ->
5858+ on_keyword:(string -> string -> unit) ->
5959+ on_data:(int -> string -> unit) ->
6060+ unit
6161+(** [iter_block ~start ~stop st ~on_keyword ~on_data] iterates over a KVN block
6262+ delimited by [start] and [stop] keywords (e.g., ["META_START"] /
6363+ ["META_STOP"]). Calls [on_keyword key value] for keyword lines and
6464+ [on_data line_num text] for data lines. Advances [st] past the stop
6565+ delimiter. *)
6666+6767+val expect_keyword : state -> string -> string option
6868+(** [expect_keyword st key] checks if the next non-blank line is [key = value]
6969+ and returns [Some value], or [None] if it's something else. Advances past
7070+ the keyword if found. *)
7171+7272+(** {1 Value parsing} *)
7373+7474+val parse_float : string -> float option
7575+(** [parse_float s] parses a float, returning [None] on failure. *)
7676+7777+val parse_int : string -> int option
7878+(** [parse_int s] parses an integer, returning [None] on failure. *)
7979+8080+val split_data : string -> string list
8181+(** [split_data s] splits a data line on whitespace, filtering empty tokens. *)
8282+8383+val parse_floats : string -> float array
8484+(** [parse_floats s] splits a data line and parses all tokens as floats.
8585+ Non-parseable tokens become [0.0]. *)
8686+8787+(** {1 Pretty-printing} *)
8888+8989+val pp_line : line Fmt.t