Skip to content
Merged
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
205 changes: 205 additions & 0 deletions scripts/seed_test_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""Seed scripts/birds.db with realistic-looking fake detections for local dev.

Generates ~3-4 weeks of history plus today, with dawn/dusk activity
clustering, a mix of common/rare species, and a handful of detections in
the last hour so "today"/"last hour" stats aren't empty. Matches the exact
schema scripts/createdb.sh creates.

Usage:
python3 scripts/seed_test_data.py # wipes and reseeds
python3 scripts/seed_test_data.py --append # adds on top of existing rows
python3 scripts/seed_test_data.py --days 14 # shorter history
"""
import argparse
import os
import random
import sqlite3
from datetime import datetime, timedelta

DB_PATH = os.path.join(os.path.dirname(__file__), 'birds.db')

# (common_name, scientific_name, relative frequency weight)
REGULAR_SPECIES = [
('Northern Cardinal', 'Cardinalis cardinalis', 10),
('Blue Jay', 'Cyanocitta cristata', 8),
('American Robin', 'Turdus migratorius', 10),
('Black-capped Chickadee', 'Poecile atricapillus', 9),
('American Goldfinch', 'Spinus tristis', 7),
('House Finch', 'Haemorhous mexicanus', 7),
('Mourning Dove', 'Zenaida macroura', 6),
('Downy Woodpecker', 'Dryobates pubescens', 5),
('White-breasted Nuthatch', 'Sitta carolinensis', 5),
('Song Sparrow', 'Melospiza melodia', 6),
('Carolina Wren', 'Thryothorus ludovicianus', 5),
('Tufted Titmouse', 'Baeolophus bicolor', 5),
('European Starling', 'Sturnus vulgaris', 6),
('Red-winged Blackbird', 'Agelaius phoeniceus', 4),
('Dark-eyed Junco', 'Junco hyemalis', 4),
('American Crow', 'Corvus brachyrhynchos', 5),
('House Sparrow', 'Passer domesticus', 6),
('Common Grackle', 'Quiscalus quiscula', 4),
('Red-breasted Nuthatch', 'Sitta canadensis', 3),
('White-throated Sparrow', 'Zonotrichia albicollis', 3),
('Common Loon', 'Gavia immer', 1),
]

NOCTURNAL_SPECIES = [
('Barred Owl', 'Strix varia', 3),
('Great Horned Owl', 'Bubo virginianus', 2),
('Eastern Screech-Owl', 'Megascops asio', 1),
]

# Only shows up in the most recent few days, to leave something for
# "new/rare species" features to have real data to test against.
RECENT_RARITIES = [
('Ruby-throated Hummingbird', 'Archilochus colubris'),
('Cedar Waxwing', 'Bombycilla cedrorum'),
]

# Matches birdnet.conf defaults, so seeded rows look like a real install.
CUTOFF = 0.7
SENS = 1.25
OVERLAP = 0.0
LAT = 0.0
LON = 0.0
AUDIOFMT = 'mp3'


def random_confidence() -> float:
# Skewed toward high confidence, occasionally near the cutoff.
return round(min(0.99, max(CUTOFF, random.betavariate(6, 2))), 4)


def random_time_of_day(is_nocturnal: bool) -> tuple[int, int, int]:
if is_nocturnal:
hour = random.choice([20, 21, 22, 23, 0, 1, 2, 3, 4])
else:
# Bimodal: dawn chorus and evening activity, with a long quiet
# midday tail and almost nothing overnight.
peak = random.choices(['dawn', 'day', 'dusk'], weights=[45, 25, 30])[0]
if peak == 'dawn':
hour = int(random.gauss(6.5, 1.2)) % 24
elif peak == 'dusk':
hour = int(random.gauss(19, 1.5)) % 24
else:
hour = random.randint(10, 16)
minute = random.randint(0, 59)
second = random.randint(0, 59)
return hour, minute, second


def build_row(day: datetime, common_name: str, sci_name: str, is_nocturnal: bool, override_time: datetime | None = None):
if override_time is not None:
dt = override_time
else:
hour, minute, second = random_time_of_day(is_nocturnal)
dt = day.replace(hour=hour, minute=minute, second=second)

date_str = dt.strftime('%Y-%m-%d')
time_str = dt.strftime('%H:%M:%S')
confidence = random_confidence()
confidence_pct = round(confidence * 100)
week = dt.isocalendar()[1]
common_name_safe = common_name.replace("'", '').replace(' ', '_')
file_name = f'{common_name_safe}-{confidence_pct}-{date_str}-birdnet-{time_str}.{AUDIOFMT}'

return (date_str, time_str, sci_name, common_name, confidence, LAT, LON, CUTOFF, week, SENS, OVERLAP, file_name)


def generate_rows(days: int):
rows = []
today = datetime.now().replace(microsecond=0)
day_starts = [today - timedelta(days=offset) for offset in range(days)]

for day in reversed(day_starts):
is_today = day.date() == today.date()
daily_count = random.randint(50, 250)

for _ in range(daily_count):
if random.random() < 0.04:
name, sci = random.choice(NOCTURNAL_SPECIES)[:2]
rows.append(build_row(day, name, sci, is_nocturnal=True))
else:
name, sci, _weight = random.choices(
REGULAR_SPECIES,
weights=[w for *_, w in REGULAR_SPECIES],
)[0]
rows.append(build_row(day, name, sci, is_nocturnal=False))

# Rare migrants only show up in the last 5 days.
if (today - day).days < 5 and random.random() < 0.6:
name, sci = random.choice(RECENT_RARITIES)
rows.append(build_row(day, name, sci, is_nocturnal=False))

# Guarantee a few detections in the last hour so "today"/"last hour"
# stats have something to show right after seeding.
if is_today:
for minutes_ago in (5, 18, 34, 52):
name, sci, _weight = random.choices(
REGULAR_SPECIES,
weights=[w for *_, w in REGULAR_SPECIES],
)[0]
rows.append(
build_row(
day,
name,
sci,
is_nocturnal=False,
override_time=today - timedelta(minutes=minutes_ago),
)
)

rows.sort(key=lambda r: (r[0], r[1]))
return rows


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--db', default=DB_PATH, help='Path to birds.db')
parser.add_argument('--days', type=int, default=28, help='Number of days of history to generate, including today')
parser.add_argument('--append', action='store_true', help="Don't clear existing rows first")
parser.add_argument('--seed', type=int, default=None, help='Random seed, for reproducible output')
args = parser.parse_args()

if args.seed is not None:
random.seed(args.seed)

con = sqlite3.connect(args.db)
cur = con.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS detections (
Date DATE,
Time TIME,
Sci_Name VARCHAR(100) NOT NULL,
Com_Name VARCHAR(100) NOT NULL,
Confidence FLOAT,
Lat FLOAT,
Lon FLOAT,
Cutoff FLOAT,
Week INT,
Sens FLOAT,
Overlap FLOAT,
File_Name VARCHAR(100) NOT NULL)
""")

if not args.append:
cur.execute('DELETE FROM detections')

rows = generate_rows(args.days)
cur.executemany(
'INSERT INTO detections VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
rows,
)
con.commit()

total = cur.execute('SELECT COUNT(*) FROM detections').fetchone()[0]
species = cur.execute('SELECT COUNT(DISTINCT Com_Name) FROM detections').fetchone()[0]
con.close()

print(f'Inserted {len(rows)} detections ({args.days} days).')
print(f'birds.db now has {total} total rows across {species} species.')


if __name__ == '__main__':
main()
3 changes: 0 additions & 3 deletions web-ui/src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { Link } from "@tanstack/react-router";

import { ThemeToggle } from "#/components/ThemeToggle.tsx";

export function Header() {
return (
<header className="border-b" style={{ background: "var(--header-bg)" }}>
Expand Down Expand Up @@ -32,7 +30,6 @@ export function Header() {
>
Species
</Link>
<ThemeToggle />
</nav>
</div>
</header>
Expand Down
33 changes: 33 additions & 0 deletions web-ui/src/components/HeroBand.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// A quiet nod to the field-guide illustrations this app's data comes
// from -- rolling hills, a treeline, one sun. Used once, on the overview
// page, so it reads as a signature rather than decoration.
export function HeroBand() {
return (
<svg
viewBox="0 0 800 140"
className="h-28 w-full text-[var(--moss)] sm:h-32"
preserveAspectRatio="none"
aria-hidden="true"
>
<circle cx="668" cy="46" r="26" fill="var(--sand)" opacity="0.85" />
<path
d="M0 96 C 120 60, 220 118, 340 82 S 560 54, 800 96 V140 H0 Z"
fill="var(--sage)"
opacity="0.55"
/>
<path
d="M0 118 C 140 96, 260 132, 420 108 S 640 88, 800 120 V140 H0 Z"
fill="var(--moss)"
opacity="0.85"
/>
{[64, 128, 190, 246, 300].map((x, i) => (
<path
key={x}
d={`M${x} ${132 - (i % 2) * 10} l16 -${34 + (i % 3) * 6} l16 ${34 + (i % 3) * 6} Z`}
fill="var(--ink)"
opacity="0.8"
/>
))}
</svg>
);
}
34 changes: 0 additions & 34 deletions web-ui/src/components/ThemeToggle.tsx

This file was deleted.

4 changes: 2 additions & 2 deletions web-ui/src/components/ui/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import type * as React from "react";
import { cn } from "#/lib/utils.ts";

const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
"bg-destructive text-white focus-visible:ring-destructive/20 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
Expand Down
9 changes: 4 additions & 5 deletions web-ui/src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,18 @@ import type * as React from "react";
import { cn } from "#/lib/utils.ts";

const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
Expand Down
14 changes: 1 addition & 13 deletions web-ui/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,6 @@ import { Footer } from "#/components/Footer.tsx";
import { Header } from "#/components/Header.tsx";
import appCss from "../styles.css?url";

// Applies the persisted theme before first paint, so there's no flash of the
// wrong theme while React hydrates.
const THEME_INIT_SCRIPT = `
(function () {
var stored = localStorage.getItem('theme');
var dark = stored ? stored === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
})();
`;

export const Route = createRootRoute({
head: () => ({
meta: [
Expand All @@ -41,11 +31,9 @@ export const Route = createRootRoute({

function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<html lang="en">
<head>
<HeadContent />
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: static constant, no user input */}
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
</head>
<body>
<div className="flex min-h-screen flex-col">
Expand Down
10 changes: 7 additions & 3 deletions web-ui/src/routes/detections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,15 @@ function Detections() {
<TableRow
key={`${detection.Date}-${detection.Time}-${detection.File_Name}`}
>
<TableCell>{detection.Date}</TableCell>
<TableCell>{detection.Time}</TableCell>
<TableCell className="tabular-data">
{detection.Date}
</TableCell>
<TableCell className="tabular-data">
{detection.Time}
</TableCell>
<TableCell>{detection.Com_Name}</TableCell>
<TableCell className="italic">{detection.Sci_Name}</TableCell>
<TableCell className="text-right">
<TableCell className="tabular-data text-right">
{detection.Confidence == null
? "—"
: `${Math.round(detection.Confidence * 100)}%`}
Expand Down
Loading