VT3A2ORJW3CJV4VZEKVQEU6XZ35RNBLMSQJOKYHATXKGMZLAZKQAC XC2HHHE5DGUU3VXNMCLJXAAWJZDAUO26KPI6JYMVCQFML4FLMDFQC EBCNGTNVY2YFFHKC4PHDEHNBOWJ4JDCEXTTJ3WKD5VWQZLLDZ65AC RFSUR7ZEXTQNHH3IFJAL2NNOTGRPWOWB3PFIVH7VLI2JPTIBMW5AC 2C4FPBSQTF4FM4J45HZGWB6L3E56U7M26TLYIRUMXC6SULR6XSQAC TLLVARZXOP2M3B5VTLF4SYDGMIBPHABE6LJFG77IU53QTSYGTKWAC W3A2EECCD23SVHJZN6MXPH2PAVFHH5CNFD2XHPQRRW6M4GUTG3FAC output.Filter = input.Filter
// Determine filter: use provided filter, or parse from CSV filenamefilter := input.Filterif filter == "" {filter = ParseFilterFromFilename(input.CSVPath)}// Filter must not be emptyif filter == "" {errMsg := "Filter must be specified via --filter flag or parsable from CSV filename"output.Error = &errMsgreturn output, fmt.Errorf("%s", errMsg)}output.Filter = filter
// buildAviaNZDataFile creates an AviaNZ .data structure from callsfunc buildAviaNZDataFile(calls []ClusteredCall, filter string, duration float64, sampleRate int) []any {
// buildAviaNZMetaAndSegments creates metadata and segments for a .data filefunc buildAviaNZMetaAndSegments(calls []ClusteredCall, filter string, duration float64, sampleRate int) (AviaNZMeta, []AviaNZSegment) {
// Build final structure: [meta, segment, segment, ...]result := make([]any, 0, 1+len(segments))result = append(result, meta)for _, seg := range segments {result = append(result, seg)}
return meta, segments}
return result
// buildAviaNZDataFile creates an AviaNZ .data structure from calls (for new files only)func buildAviaNZDataFile(calls []ClusteredCall, filter string, duration float64, sampleRate int) []any {meta, segments := buildAviaNZMetaAndSegments(calls, filter, duration, sampleRate)return buildDataFileFromSegments(meta, segments)
// writeDotDataFileSafe safely writes or merges .data files// - If file doesn't exist: write new file// - If file exists with same filter: return error (refuse to clobber)// - If file exists with different filter: merge segments and write// - If file exists but can't be parsed: return error (refuse to clobber)func writeDotDataFileSafe(path string, newSegments []AviaNZSegment, filter string, meta AviaNZMeta) error {// Check if file existsif _, err := os.Stat(path); err == nil {// File exists - parse and checkexisting, err := utils.ParseDataFile(path)if err != nil {return fmt.Errorf("cannot parse existing %s: %w (refusing to clobber)", path, err)}// Check for duplicate filterfor _, seg := range existing.Segments {if seg.HasFilterLabel(filter) {return fmt.Errorf("%s already contains filter '%s' (refusing to clobber)", path, filter)}}
// Append new segments (different filter - safe to merge)for _, newSeg := range newSegments {seg := convertAviaNZSegment(newSeg, filter)existing.Segments = append(existing.Segments, seg)}// Sort by start timesort.Slice(existing.Segments, func(i, j int) bool {return existing.Segments[i].StartTime < existing.Segments[j].StartTime})return existing.Write(path)}// File doesn't exist - write newdata := buildDataFileFromSegments(meta, newSegments)return writeAviaNZDataFile(path, data)}// convertAviaNZSegment converts an AviaNZSegment to utils.Segmentfunc convertAviaNZSegment(seg AviaNZSegment, filter string) *utils.Segment {labels := seg[4].([]AviaNZLabel)utilsLabels := make([]*utils.Label, len(labels))for i, l := range labels {utilsLabels[i] = &utils.Label{Species: l.Species,Certainty: l.Certainty,Filter: filter,}}// Handle freq values (could be int or float64 depending on how they were created)var freqLow, freqHigh float64switch v := seg[2].(type) {case int:freqLow = float64(v)case float64:freqLow = v}switch v := seg[3].(type) {case int:freqHigh = float64(v)case float64:freqHigh = v}return &utils.Segment{StartTime: seg[0].(float64),EndTime: seg[1].(float64),FreqLow: freqLow,FreqHigh: freqHigh,Labels: utilsLabels,}}// buildDataFileFromSegments builds the data file structure from meta and segmentsfunc buildDataFileFromSegments(meta AviaNZMeta, segments []AviaNZSegment) []any {result := make([]any, 0, 1+len(segments))result = append(result, meta)for _, seg := range segments {result = append(result, seg)}return result}
## [2026-03-09] Safe .data File Writing in calls-from-preds**Breaking change:** Filter must now be non-empty. Previously empty filter was allowed.**Problem:** `calls-from-preds --write-dot-data` would silently clobber existing `.data` files, potentially destroying manual annotations.**Solution:** Implemented safe write logic that protects existing data:1. **No existing file** → Write new file (unchanged behavior)2. **Existing file, same filter** → Error: "file already contains filter 'X' (refusing to clobber)"3. **Existing file, different filter** → Merge segments (append new, sort by time)4. **Existing file, parse error** → Error: "cannot parse existing file (refusing to clobber)"
**Changes:**- `tools/calls_from_preds.go` — Added `writeDotDataFileSafe()` for safe write/merge logic- `tools/calls_from_preds.go` — Added filter validation: empty filter now returns error- `tools/calls_from_preds.go` — Filter defaults to CSV filename parsing if `--filter` not specified- `tools/calls_from_preds.go` — Added `convertAviaNZSegment()` and `buildAviaNZMetaAndSegments()` helpers**Filter logic:**- If `--filter "name"` specified → use that filter- If `--filter` not specified → parse from CSV filename (e.g., `predsST_opensoundscape-kiwi-1.2_2025-11-12.csv` → `opensoundscape-kiwi-1.2`)- If filter is empty string → error**Error handling:** First error stops batch processing (existing behavior preserved).