package tools
import (
"fmt"
"os"
"skraak/utils"
)
type CallsPropagateInput struct {
File string `json:"file"`
FromFilter string `json:"from_filter"`
ToFilter string `json:"to_filter"`
Species string `json:"species"`
}
type CallsPropagateOutput struct {
File string `json:"file"`
FromFilter string `json:"from_filter"`
ToFilter string `json:"to_filter"`
Species string `json:"species"`
FiltersMissing bool `json:"filters_missing,omitempty"`
TargetsExamined int `json:"targets_examined"`
Propagated int `json:"propagated"`
SkippedNoOverlap int `json:"skipped_no_overlap"`
SkippedConflict int `json:"skipped_conflict"`
Conflicts []PropagateConflict `json:"conflicts,omitempty"`
Changes []PropagateChange `json:"changes,omitempty"`
Error string `json:"error,omitempty"`
}
type CallsPropagateFolderInput struct {
Folder string `json:"folder"`
FromFilter string `json:"from_filter"`
ToFilter string `json:"to_filter"`
Species string `json:"species"`
}
type CallsPropagateFolderOutput struct {
Folder string `json:"folder"`
FromFilter string `json:"from_filter"`
ToFilter string `json:"to_filter"`
Species string `json:"species"`
FilesTotal int `json:"files_total"`
FilesWithBothFilters int `json:"files_with_both_filters"`
FilesSkippedNoFilter int `json:"files_skipped_no_filter"`
FilesChanged int `json:"files_changed"`
FilesErrored int `json:"files_errored"`
TargetsExamined int `json:"targets_examined"`
Propagated int `json:"propagated"`
SkippedNoOverlap int `json:"skipped_no_overlap"`
SkippedConflict int `json:"skipped_conflict"`
Conflicts []PropagateConflict `json:"conflicts,omitempty"`
Errors []CallsPropagateOutput `json:"errors,omitempty"`
Error string `json:"error,omitempty"`
}
type PropagateConflict struct {
File string `json:"file,omitempty"`
TargetStart float64 `json:"target_start"`
TargetEnd float64 `json:"target_end"`
TargetCallType string `json:"target_calltype,omitempty"`
SourceChoices []PropagateSourceChoice `json:"source_choices"`
}
type PropagateSourceChoice struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Species string `json:"species"`
CallType string `json:"calltype,omitempty"`
}
type PropagateChange struct {
TargetStart float64 `json:"target_start"`
TargetEnd float64 `json:"target_end"`
PrevSpecies string `json:"prev_species"`
PrevCallType string `json:"prev_calltype,omitempty"`
PrevCertainty int `json:"prev_certainty"`
NewSpecies string `json:"new_species"`
NewCallType string `json:"new_calltype,omitempty"`
NewCertainty int `json:"new_certainty"`
}
func CallsPropagate(input CallsPropagateInput) (CallsPropagateOutput, error) {
output := CallsPropagateOutput{
File: input.File,
FromFilter: input.FromFilter,
ToFilter: input.ToFilter,
Species: input.Species,
}
if err := validatePropagateInput(&output, input); err != nil {
return output, err
}
df, err := utils.ParseDataFile(input.File)
if err != nil {
output.Error = fmt.Sprintf("parse %s: %v", input.File, err)
return output, fmt.Errorf("%s", output.Error)
}
if !hasBothFilters(df, input.FromFilter, input.ToFilter) {
output.FiltersMissing = true
return output, nil
}
sources := collectPropagateSources(df, input.FromFilter, input.Species)
propagateTargets(df, sources, input, &output)
if output.Propagated > 0 {
df.Meta.Reviewer = "Skraak"
if err := df.Write(input.File); err != nil {
output.Error = fmt.Sprintf("write %s: %v", input.File, err)
return output, fmt.Errorf("%s", output.Error)
}
}
return output, nil
}
func validatePropagateInput(output *CallsPropagateOutput, input CallsPropagateInput) error {
checks := []struct {
val string
msg string
}{
{input.File, "--file is required"},
{input.FromFilter, "--from is required"},
{input.ToFilter, "--to is required"},
{input.Species, "--species is required"},
}
for _, c := range checks {
if c.val == "" {
output.Error = c.msg
return fmt.Errorf("%s", c.msg)
}
}
if input.FromFilter == input.ToFilter {
output.Error = "--from and --to must differ"
return fmt.Errorf("%s", output.Error)
}
if _, err := os.Stat(input.File); os.IsNotExist(err) {
output.Error = fmt.Sprintf("file not found: %s", input.File)
return fmt.Errorf("%s", output.Error)
}
return nil
}
func hasBothFilters(df *utils.DataFile, fromFilter, toFilter string) bool {
hasFrom, hasTo := false, false
for _, seg := range df.Segments {
for _, lbl := range seg.Labels {
if lbl.Filter == fromFilter {
hasFrom = true
}
if lbl.Filter == toFilter {
hasTo = true
}
if hasFrom && hasTo {
return true
}
}
}
return false
}
type sourceRef struct {
seg *utils.Segment
label *utils.Label
}
func collectPropagateSources(df *utils.DataFile, fromFilter, species string) []sourceRef {
var sources []sourceRef
for _, seg := range df.Segments {
for _, lbl := range seg.Labels {
if lbl.Filter == fromFilter && lbl.Species == species && lbl.Certainty == 100 {
sources = append(sources, sourceRef{seg: seg, label: lbl})
break
}
}
}
return sources
}
func propagateTargets(df *utils.DataFile, sources []sourceRef, input CallsPropagateInput, output *CallsPropagateOutput) {
for _, tSeg := range df.Segments {
toLabel := findUpdatableTargetLabel(tSeg.Labels, input.ToFilter)
if toLabel == nil {
continue
}
output.TargetsExamined++
overlaps := findOverlappingSources(sources, tSeg)
if len(overlaps) == 0 {
output.SkippedNoOverlap++
continue
}
agreedCallType, conflict := resolveCallType(overlaps)
if conflict {
output.SkippedConflict++
output.Conflicts = append(output.Conflicts, buildConflictRecord(tSeg, toLabel, overlaps))
continue
}
applyPropagation(toLabel, input.Species, agreedCallType, tSeg, output)
}
}
func findUpdatableTargetLabel(labels []*utils.Label, toFilter string) *utils.Label {
for _, lbl := range labels {
if lbl.Filter == toFilter && (lbl.Certainty == 70 || lbl.Certainty == 0) {
return lbl
}
}
return nil
}
func findOverlappingSources(sources []sourceRef, tSeg *utils.Segment) []sourceRef {
var overlaps []sourceRef
for _, s := range sources {
if s.seg.StartTime < tSeg.EndTime && tSeg.StartTime < s.seg.EndTime {
overlaps = append(overlaps, s)
}
}
return overlaps
}
func resolveCallType(overlaps []sourceRef) (string, bool) {
agreedCallType := overlaps[0].label.CallType
for _, s := range overlaps[1:] {
if s.label.CallType != agreedCallType {
return "", true
}
}
return agreedCallType, false
}
func buildConflictRecord(tSeg *utils.Segment, toLabel *utils.Label, overlaps []sourceRef) PropagateConflict {
choices := make([]PropagateSourceChoice, 0, len(overlaps))
for _, s := range overlaps {
choices = append(choices, PropagateSourceChoice{
Start: s.seg.StartTime,
End: s.seg.EndTime,
Species: s.label.Species,
CallType: s.label.CallType,
})
}
return PropagateConflict{
TargetStart: tSeg.StartTime,
TargetEnd: tSeg.EndTime,
TargetCallType: toLabel.CallType,
SourceChoices: choices,
}
}
func applyPropagation(toLabel *utils.Label, species, callType string, tSeg *utils.Segment, output *CallsPropagateOutput) {
change := PropagateChange{
TargetStart: tSeg.StartTime,
TargetEnd: tSeg.EndTime,
PrevSpecies: toLabel.Species,
PrevCallType: toLabel.CallType,
PrevCertainty: toLabel.Certainty,
NewSpecies: species,
NewCallType: callType,
NewCertainty: 90,
}
toLabel.Species = species
toLabel.CallType = callType
toLabel.Certainty = 90
output.Propagated++
output.Changes = append(output.Changes, change)
}
func CallsPropagateFolder(input CallsPropagateFolderInput) (CallsPropagateFolderOutput, error) {
output := CallsPropagateFolderOutput{
Folder: input.Folder,
FromFilter: input.FromFilter,
ToFilter: input.ToFilter,
Species: input.Species,
}
if input.Folder == "" {
output.Error = "--folder is required"
return output, fmt.Errorf("%s", output.Error)
}
if input.FromFilter == "" {
output.Error = "--from is required"
return output, fmt.Errorf("%s", output.Error)
}
if input.ToFilter == "" {
output.Error = "--to is required"
return output, fmt.Errorf("%s", output.Error)
}
if input.Species == "" {
output.Error = "--species is required"
return output, fmt.Errorf("%s", output.Error)
}
if input.FromFilter == input.ToFilter {
output.Error = "--from and --to must differ"
return output, fmt.Errorf("%s", output.Error)
}
info, err := os.Stat(input.Folder)
if err != nil {
output.Error = fmt.Sprintf("folder not found: %s", input.Folder)
return output, fmt.Errorf("%s", output.Error)
}
if !info.IsDir() {
output.Error = fmt.Sprintf("not a directory: %s", input.Folder)
return output, fmt.Errorf("%s", output.Error)
}
files, err := utils.FindDataFiles(input.Folder)
if err != nil {
output.Error = fmt.Sprintf("list .data files: %v", err)
return output, fmt.Errorf("%s", output.Error)
}
output.FilesTotal = len(files)
for _, f := range files {
fileOut, err := CallsPropagate(CallsPropagateInput{
File: f,
FromFilter: input.FromFilter,
ToFilter: input.ToFilter,
Species: input.Species,
})
if err != nil {
output.FilesErrored++
output.Errors = append(output.Errors, fileOut)
continue
}
if fileOut.FiltersMissing {
output.FilesSkippedNoFilter++
continue
}
output.FilesWithBothFilters++
output.TargetsExamined += fileOut.TargetsExamined
output.Propagated += fileOut.Propagated
output.SkippedNoOverlap += fileOut.SkippedNoOverlap
output.SkippedConflict += fileOut.SkippedConflict
if fileOut.Propagated > 0 {
output.FilesChanged++
}
for _, c := range fileOut.Conflicts {
c.File = f
output.Conflicts = append(output.Conflicts, c)
}
}
return output, nil
}