Initial import after prompting claude
This commit is contained in:
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["python", "-m", "gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "app:app"]
|
||||||
0
analyzer/__init__.py
Normal file
0
analyzer/__init__.py
Normal file
BIN
analyzer/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
analyzer/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
BIN
analyzer/__pycache__/charts.cpython-311.pyc
Normal file
BIN
analyzer/__pycache__/charts.cpython-311.pyc
Normal file
Binary file not shown.
BIN
analyzer/__pycache__/grouper.cpython-311.pyc
Normal file
BIN
analyzer/__pycache__/grouper.cpython-311.pyc
Normal file
Binary file not shown.
BIN
analyzer/__pycache__/parser.cpython-311.pyc
Normal file
BIN
analyzer/__pycache__/parser.cpython-311.pyc
Normal file
Binary file not shown.
BIN
analyzer/__pycache__/pdf_report.cpython-311.pyc
Normal file
BIN
analyzer/__pycache__/pdf_report.cpython-311.pyc
Normal file
Binary file not shown.
BIN
analyzer/__pycache__/stats.cpython-311.pyc
Normal file
BIN
analyzer/__pycache__/stats.cpython-311.pyc
Normal file
Binary file not shown.
82
analyzer/charts.py
Normal file
82
analyzer/charts.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import io
|
||||||
|
import base64
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib.dates as mdates
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def render_group_charts(groups: list, y_min: float, y_max: float) -> list:
|
||||||
|
padding_fraction = 0.05
|
||||||
|
y_range = y_max - y_min
|
||||||
|
if y_range == 0:
|
||||||
|
y_pad = 1.0
|
||||||
|
else:
|
||||||
|
y_pad = y_range * padding_fraction
|
||||||
|
|
||||||
|
charts = []
|
||||||
|
for i, g in enumerate(groups):
|
||||||
|
fig, ax = plt.subplots(figsize=(9, 4))
|
||||||
|
|
||||||
|
x = g["time"]
|
||||||
|
y = g["speed"]
|
||||||
|
|
||||||
|
ax.plot(x, y, marker="o", linewidth=1.5, markersize=5, color="#1f77b4")
|
||||||
|
|
||||||
|
ax.set_ylim(y_min - y_pad, y_max + y_pad)
|
||||||
|
|
||||||
|
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S"))
|
||||||
|
fig.autofmt_xdate(rotation=30)
|
||||||
|
|
||||||
|
ax.set_title(f"Group {i + 1} — {len(g)} shot(s)")
|
||||||
|
ax.set_xlabel("Time of Day")
|
||||||
|
ax.set_ylabel("Speed")
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
fig.tight_layout()
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format="png", dpi=100)
|
||||||
|
plt.close(fig)
|
||||||
|
buf.seek(0)
|
||||||
|
charts.append(base64.b64encode(buf.read()).decode("utf-8"))
|
||||||
|
|
||||||
|
return charts
|
||||||
|
|
||||||
|
|
||||||
|
def render_overview_chart(group_stats: list) -> str:
|
||||||
|
"""Dual-axis line chart: avg speed and avg std dev per group."""
|
||||||
|
indices = [s["group_index"] for s in group_stats]
|
||||||
|
speeds = [s["mean_speed"] for s in group_stats]
|
||||||
|
stds = [s["std_speed"] if s["std_speed"] is not None else 0.0 for s in group_stats]
|
||||||
|
|
||||||
|
fig, ax1 = plt.subplots(figsize=(7, 3))
|
||||||
|
|
||||||
|
color_speed = "#1f77b4"
|
||||||
|
color_std = "#d62728"
|
||||||
|
|
||||||
|
ax1.plot(indices, speeds, marker="o", linewidth=1.8, markersize=5,
|
||||||
|
color=color_speed, label="Avg speed")
|
||||||
|
ax1.set_xlabel("Group")
|
||||||
|
ax1.set_ylabel("Avg speed", color=color_speed)
|
||||||
|
ax1.tick_params(axis="y", labelcolor=color_speed)
|
||||||
|
ax1.set_xticks(indices)
|
||||||
|
|
||||||
|
ax2 = ax1.twinx()
|
||||||
|
ax2.plot(indices, stds, marker="s", linewidth=1.8, markersize=5,
|
||||||
|
color=color_std, linestyle="--", label="Avg std dev")
|
||||||
|
ax2.set_ylabel("Avg std dev", color=color_std)
|
||||||
|
ax2.tick_params(axis="y", labelcolor=color_std)
|
||||||
|
|
||||||
|
lines1, labels1 = ax1.get_legend_handles_labels()
|
||||||
|
lines2, labels2 = ax2.get_legend_handles_labels()
|
||||||
|
ax1.legend(lines1 + lines2, labels1 + labels2, fontsize=8, loc="upper right")
|
||||||
|
|
||||||
|
ax1.grid(True, alpha=0.3)
|
||||||
|
fig.tight_layout()
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format="png", dpi=100)
|
||||||
|
plt.close(fig)
|
||||||
|
buf.seek(0)
|
||||||
|
return base64.b64encode(buf.read()).decode("utf-8")
|
||||||
44
analyzer/grouper.py
Normal file
44
analyzer/grouper.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
OUTLIER_FACTOR = 5
|
||||||
|
|
||||||
|
|
||||||
|
def detect_groups(df: pd.DataFrame) -> list:
|
||||||
|
if len(df) <= 1:
|
||||||
|
return [df]
|
||||||
|
|
||||||
|
times = df["time"]
|
||||||
|
diffs = times.diff().dropna()
|
||||||
|
|
||||||
|
if diffs.empty:
|
||||||
|
return [df]
|
||||||
|
|
||||||
|
median_gap = diffs.median()
|
||||||
|
|
||||||
|
if median_gap == timedelta(0):
|
||||||
|
return [df]
|
||||||
|
|
||||||
|
threshold = OUTLIER_FACTOR * median_gap
|
||||||
|
|
||||||
|
split_positions = []
|
||||||
|
for idx, gap in diffs.items():
|
||||||
|
if gap > threshold:
|
||||||
|
pos = df.index.get_loc(idx)
|
||||||
|
split_positions.append(pos)
|
||||||
|
|
||||||
|
if not split_positions:
|
||||||
|
return [df]
|
||||||
|
|
||||||
|
groups = []
|
||||||
|
prev = 0
|
||||||
|
for pos in split_positions:
|
||||||
|
group = df.iloc[prev:pos]
|
||||||
|
if len(group) > 0:
|
||||||
|
groups.append(group.reset_index(drop=True))
|
||||||
|
prev = pos
|
||||||
|
last = df.iloc[prev:]
|
||||||
|
if len(last) > 0:
|
||||||
|
groups.append(last.reset_index(drop=True))
|
||||||
|
|
||||||
|
return groups
|
||||||
107
analyzer/parser.py
Normal file
107
analyzer/parser.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import pandas as pd
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
CANONICAL_COLS = ["idx", "speed", "std_dev", "energy", "power_factor", "time"]
|
||||||
|
TIME_FORMATS = ["%H:%M:%S.%f", "%H:%M:%S", "%H:%M:%S,%f"]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_csv(stream) -> pd.DataFrame:
|
||||||
|
raw = stream.read()
|
||||||
|
if isinstance(raw, bytes):
|
||||||
|
raw = raw.decode("utf-8-sig")
|
||||||
|
# Strip BOM characters that may appear anywhere in the file
|
||||||
|
raw = raw.replace("\ufeff", "")
|
||||||
|
|
||||||
|
data_rows = []
|
||||||
|
for line in raw.splitlines():
|
||||||
|
fields = _split_line(line)
|
||||||
|
if len(fields) >= 6 and _is_index(fields[0]) and _is_time(fields[5]):
|
||||||
|
data_rows.append(fields[:6])
|
||||||
|
|
||||||
|
if len(data_rows) < 2:
|
||||||
|
raise ValueError(
|
||||||
|
"Could not find valid data rows in the CSV. "
|
||||||
|
"Expected rows with: integer index, 4 numeric values, and a time (HH:MM:SS)."
|
||||||
|
)
|
||||||
|
|
||||||
|
df = pd.DataFrame(data_rows, columns=CANONICAL_COLS)
|
||||||
|
|
||||||
|
for col in ("speed", "std_dev", "energy", "power_factor"):
|
||||||
|
df[col] = _parse_numeric(df[col])
|
||||||
|
|
||||||
|
df["time"] = _parse_time_column(df["time"])
|
||||||
|
df = df.sort_values("time").reset_index(drop=True)
|
||||||
|
return df[["speed", "std_dev", "energy", "power_factor", "time"]]
|
||||||
|
|
||||||
|
|
||||||
|
def _split_line(line: str) -> list:
|
||||||
|
"""Parse one CSV line, respecting quoted fields."""
|
||||||
|
for row in csv.reader([line], quotechar='"', doublequote=True, skipinitialspace=True):
|
||||||
|
return [f.strip() for f in row]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _is_index(val: str) -> bool:
|
||||||
|
"""True if the value is a non-negative integer (auto-increment row index)."""
|
||||||
|
try:
|
||||||
|
return int(val.strip()) >= 0
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_time(val: str) -> bool:
|
||||||
|
"""True if the value parses as HH:MM:SS or HH:MM:SS.fff."""
|
||||||
|
cleaned = val.strip()
|
||||||
|
for fmt in TIME_FORMATS:
|
||||||
|
try:
|
||||||
|
datetime.strptime(cleaned, fmt)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_numeric(col: pd.Series) -> pd.Series:
|
||||||
|
"""Parse a numeric column, accepting both '.' and ',' as decimal separator."""
|
||||||
|
result = pd.to_numeric(col, errors="coerce")
|
||||||
|
if result.isna().any():
|
||||||
|
result = pd.to_numeric(
|
||||||
|
col.astype(str).str.replace(",", ".", regex=False),
|
||||||
|
errors="coerce",
|
||||||
|
)
|
||||||
|
if result.isna().any():
|
||||||
|
bad = col[result.isna()].tolist()
|
||||||
|
raise ValueError(f"Non-numeric values in column: {bad}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time_column(col: pd.Series) -> pd.Series:
|
||||||
|
today = datetime.today().date()
|
||||||
|
cleaned = col.astype(str).str.strip()
|
||||||
|
|
||||||
|
parsed = None
|
||||||
|
for fmt in TIME_FORMATS:
|
||||||
|
candidate = pd.to_datetime(cleaned, format=fmt, errors="coerce")
|
||||||
|
if candidate.notna().all():
|
||||||
|
parsed = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
if parsed is None:
|
||||||
|
candidate = pd.to_datetime(cleaned, errors="coerce")
|
||||||
|
if candidate.notna().all():
|
||||||
|
parsed = candidate
|
||||||
|
|
||||||
|
if parsed is None:
|
||||||
|
raise ValueError(
|
||||||
|
"Could not parse time column. Expected format: HH:MM:SS or HH:MM:SS.fff"
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed = parsed.apply(lambda t: datetime.combine(today, t.time()))
|
||||||
|
|
||||||
|
times = parsed.tolist()
|
||||||
|
for i in range(1, len(times)):
|
||||||
|
if times[i] < times[i - 1]:
|
||||||
|
times[i] += timedelta(days=1)
|
||||||
|
return pd.Series(times, index=col.index)
|
||||||
95
analyzer/pdf_report.py
Normal file
95
analyzer/pdf_report.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import base64
|
||||||
|
import io
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fpdf import FPDF
|
||||||
|
|
||||||
|
_COL_LABEL = 80
|
||||||
|
_COL_VALUE = 50
|
||||||
|
_ROW_H = 7
|
||||||
|
|
||||||
|
|
||||||
|
def generate_pdf(overall: dict, group_stats: list, charts: list, overview_chart: str) -> bytes:
|
||||||
|
pdf = FPDF()
|
||||||
|
pdf.set_auto_page_break(auto=True, margin=15)
|
||||||
|
|
||||||
|
pdf.add_page()
|
||||||
|
_title_block(pdf)
|
||||||
|
_overall_section(pdf, overall, overview_chart)
|
||||||
|
|
||||||
|
for stat, chart_b64 in zip(group_stats, charts):
|
||||||
|
_group_section(pdf, stat, chart_b64)
|
||||||
|
|
||||||
|
return bytes(pdf.output())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _title_block(pdf: FPDF):
|
||||||
|
pdf.set_font("Helvetica", "B", 18)
|
||||||
|
pdf.cell(0, 12, "Ballistic Analysis Report", new_x="LMARGIN", new_y="NEXT", align="C")
|
||||||
|
pdf.set_font("Helvetica", "", 9)
|
||||||
|
pdf.cell(
|
||||||
|
0, 5,
|
||||||
|
f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
||||||
|
new_x="LMARGIN", new_y="NEXT", align="C",
|
||||||
|
)
|
||||||
|
pdf.ln(8)
|
||||||
|
|
||||||
|
|
||||||
|
def _overall_section(pdf: FPDF, overall: dict, overview_chart: str):
|
||||||
|
_section_heading(pdf, "Overall Statistics")
|
||||||
|
rows = [
|
||||||
|
("Total shots", str(overall["count"])),
|
||||||
|
("Min speed", f"{overall['min_speed']:.4f}"),
|
||||||
|
("Max speed", f"{overall['max_speed']:.4f}"),
|
||||||
|
("Mean speed", f"{overall['mean_speed']:.4f}"),
|
||||||
|
("Std dev (speed)", f"{overall['std_speed']:.4f}" if overall["std_speed"] is not None else "n/a"),
|
||||||
|
]
|
||||||
|
_table(pdf, rows)
|
||||||
|
img_bytes = base64.b64decode(overview_chart)
|
||||||
|
pdf.image(io.BytesIO(img_bytes), x=pdf.l_margin, w=min(140, pdf.epw))
|
||||||
|
pdf.ln(4)
|
||||||
|
|
||||||
|
|
||||||
|
def _group_section(pdf: FPDF, stat: dict, chart_b64: str):
|
||||||
|
pdf.ln(4)
|
||||||
|
heading = (
|
||||||
|
f"Group {stat['group_index']} - "
|
||||||
|
f"{stat['time_start']} to {stat['time_end']} "
|
||||||
|
f"({stat['count']} shot(s))"
|
||||||
|
)
|
||||||
|
_section_heading(pdf, heading)
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
("Min speed", f"{stat['min_speed']:.4f}"),
|
||||||
|
("Max speed", f"{stat['max_speed']:.4f}"),
|
||||||
|
("Mean speed", f"{stat['mean_speed']:.4f}"),
|
||||||
|
("Std dev (speed)", f"{stat['std_speed']:.4f}" if stat["std_speed"] is not None else "n/a"),
|
||||||
|
]
|
||||||
|
_table(pdf, rows)
|
||||||
|
|
||||||
|
img_bytes = base64.b64decode(chart_b64)
|
||||||
|
# Check remaining page space; add new page if chart won't fit
|
||||||
|
if pdf.get_y() + 75 > pdf.page_break_trigger:
|
||||||
|
pdf.add_page()
|
||||||
|
pdf.image(io.BytesIO(img_bytes), x=pdf.l_margin, w=pdf.epw)
|
||||||
|
pdf.ln(4)
|
||||||
|
|
||||||
|
|
||||||
|
def _section_heading(pdf: FPDF, text: str):
|
||||||
|
pdf.set_font("Helvetica", "B", 12)
|
||||||
|
pdf.set_fill_color(230, 236, 255)
|
||||||
|
pdf.cell(0, 8, text, new_x="LMARGIN", new_y="NEXT", fill=True)
|
||||||
|
pdf.ln(2)
|
||||||
|
|
||||||
|
|
||||||
|
def _table(pdf: FPDF, rows: list):
|
||||||
|
for i, (label, value) in enumerate(rows):
|
||||||
|
fill = i % 2 == 0
|
||||||
|
pdf.set_fill_color(248, 249, 252) if fill else pdf.set_fill_color(255, 255, 255)
|
||||||
|
pdf.set_font("Helvetica", "", 10)
|
||||||
|
pdf.cell(_COL_LABEL, _ROW_H, label, border=0, fill=fill)
|
||||||
|
pdf.set_font("Helvetica", "B", 10)
|
||||||
|
pdf.cell(_COL_VALUE, _ROW_H, value, border=0, fill=fill, new_x="LMARGIN", new_y="NEXT")
|
||||||
|
pdf.ln(3)
|
||||||
30
analyzer/stats.py
Normal file
30
analyzer/stats.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def compute_overall_stats(df: pd.DataFrame) -> dict:
|
||||||
|
s = df["speed"]
|
||||||
|
return {
|
||||||
|
"min_speed": s.min(),
|
||||||
|
"max_speed": s.max(),
|
||||||
|
"mean_speed": s.mean(),
|
||||||
|
"std_speed": s.std(ddof=1),
|
||||||
|
"count": len(df),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_group_stats(groups: list) -> list:
|
||||||
|
result = []
|
||||||
|
for i, g in enumerate(groups):
|
||||||
|
s = g["speed"]
|
||||||
|
std = s.std(ddof=1) if len(g) > 1 else None
|
||||||
|
result.append({
|
||||||
|
"group_index": i + 1,
|
||||||
|
"count": len(g),
|
||||||
|
"min_speed": s.min(),
|
||||||
|
"max_speed": s.max(),
|
||||||
|
"mean_speed": s.mean(),
|
||||||
|
"std_speed": std,
|
||||||
|
"time_start": g["time"].min().strftime("%H:%M:%S"),
|
||||||
|
"time_end": g["time"].max().strftime("%H:%M:%S"),
|
||||||
|
})
|
||||||
|
return result
|
||||||
55
app.py
Normal file
55
app.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import base64
|
||||||
|
|
||||||
|
from flask import Flask, request, render_template
|
||||||
|
|
||||||
|
from analyzer.parser import parse_csv
|
||||||
|
from analyzer.grouper import detect_groups
|
||||||
|
from analyzer.stats import compute_overall_stats, compute_group_stats
|
||||||
|
from analyzer.charts import render_group_charts, render_overview_chart
|
||||||
|
from analyzer.pdf_report import generate_pdf
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template("upload.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/analyze", methods=["POST"])
|
||||||
|
def analyze():
|
||||||
|
if "csv_file" not in request.files or request.files["csv_file"].filename == "":
|
||||||
|
return render_template("upload.html", error="No file selected.")
|
||||||
|
|
||||||
|
file = request.files["csv_file"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = parse_csv(file.stream)
|
||||||
|
groups = detect_groups(df)
|
||||||
|
overall = compute_overall_stats(df)
|
||||||
|
group_stats = compute_group_stats(groups)
|
||||||
|
charts = render_group_charts(
|
||||||
|
groups,
|
||||||
|
y_min=overall["min_speed"],
|
||||||
|
y_max=overall["max_speed"],
|
||||||
|
)
|
||||||
|
overview_chart = render_overview_chart(group_stats)
|
||||||
|
except ValueError as e:
|
||||||
|
return render_template("upload.html", error=str(e))
|
||||||
|
|
||||||
|
pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart)
|
||||||
|
pdf_b64 = base64.b64encode(pdf_bytes).decode("utf-8")
|
||||||
|
|
||||||
|
groups_display = list(zip(group_stats, charts))
|
||||||
|
return render_template(
|
||||||
|
"results.html",
|
||||||
|
overall=overall,
|
||||||
|
groups_display=groups_display,
|
||||||
|
overview_chart=overview_chart,
|
||||||
|
pdf_b64=pdf_b64,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(debug=True)
|
||||||
6
docker-compose.yaml
Normal file
6
docker-compose.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
restart: unless-stopped
|
||||||
11
memory/feedback_no_host_install.md
Normal file
11
memory/feedback_no_host_install.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: no_host_pip_install
|
||||||
|
description: Do not run pip install on the host machine; user is on Debian with externally-managed Python. All dependency testing must happen inside Docker containers.
|
||||||
|
type: feedback
|
||||||
|
---
|
||||||
|
|
||||||
|
Do not run `pip install` on the host machine.
|
||||||
|
|
||||||
|
**Why:** The host is Debian with an externally-managed Python environment; pip installs are blocked system-wide.
|
||||||
|
|
||||||
|
**How to apply:** Any testing that requires libraries not already on the host must be done inside a Docker container (`docker compose run` or `docker build`). Skip host-level install tests entirely and note to the user that they should verify inside the container.
|
||||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Flask>=3.0
|
||||||
|
pandas>=1.5
|
||||||
|
matplotlib>=3.6
|
||||||
|
numpy>=1.24
|
||||||
|
gunicorn>=21.0
|
||||||
|
fpdf2>=2.7
|
||||||
63
sample.csv
Normal file
63
sample.csv
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
Balle de fusil 169,0 gr
|
||||||
|
#,Vitesse (MPS),Δ Moyenne (MPS),ÉK (J),Facteur de puissance (N s),Temps,Âme nettoyée,Âme froide,Notes de tir
|
||||||
|
1,"807,4","6,1","3569,4","8,8",09:30:37,,,
|
||||||
|
2,"801,1","-0,2","3514,0","8,8",09:31:06,,,
|
||||||
|
3,"799,3","-2,0","3498,4","8,8",09:31:28,,,
|
||||||
|
4,"800,1","-1,3","3505,0","8,8",09:32:17,,,
|
||||||
|
5,"800,3","-1,0","3507,1","8,8",09:32:36,,,
|
||||||
|
6,"789,5","-11,8","3412,9","8,6",09:49:40,,,
|
||||||
|
7,"800,7","-0,7","3510,2","8,8",09:49:54,,,
|
||||||
|
8,"795,1","-6,2","3461,6","8,7",09:50:10,,,
|
||||||
|
9,"799,8","-1,5","3502,6","8,8",09:50:27,,,
|
||||||
|
10,"798,9","-2,4","3494,8","8,7",09:50:57,,,
|
||||||
|
11,"795,4","-5,9","3464,0","8,7",09:53:04,,,
|
||||||
|
12,"798,6","-2,7","3492,0","8,7",09:53:15,,,
|
||||||
|
13,"798,1","-3,2","3487,8","8,7",09:53:28,,,
|
||||||
|
14,"798,3","-3,0","3489,3","8,7",09:53:44,,,
|
||||||
|
15,"797,6","-3,7","3483,7","8,7",09:54:02,,,
|
||||||
|
16,"800,3","-1,0","3506,9","8,8",10:08:55,,,
|
||||||
|
17,"800,6","-0,7","3509,9","8,8",10:09:24,,,
|
||||||
|
18,"804,6","3,2","3544,5","8,8",10:09:38,,,
|
||||||
|
19,"799,8","-1,6","3502,4","8,8",10:09:55,,,
|
||||||
|
20,"802,1","0,8","3523,0","8,8",10:10:15,,,
|
||||||
|
21,"802,0","0,6","3521,7","8,8",10:26:11,,,
|
||||||
|
22,"804,0","2,7","3539,7","8,8",10:26:32,,,
|
||||||
|
23,"805,1","3,8","3549,4","8,8",10:27:23,,,
|
||||||
|
24,"802,8","1,5","3529,3","8,8",10:27:50,,,
|
||||||
|
25,"805,8","4,4","3555,1","8,8",10:28:15,,,
|
||||||
|
26,"808,1","6,8","3575,9","8,8",10:43:45,,,
|
||||||
|
27,"798,5","-2,9","3490,8","8,7",10:44:04,,,
|
||||||
|
28,"798,4","-3,0","3489,9","8,7",10:44:20,,,
|
||||||
|
29,"801,6","0,3","3518,7","8,8",10:44:48,,,
|
||||||
|
30,"800,1","-1,2","3505,5","8,8",10:45:10,,,
|
||||||
|
31,"802,4","1,1","3525,3","8,8",10:57:50,,,
|
||||||
|
32,"796,9","-4,5","3477,1","8,7",10:58:08,,,
|
||||||
|
33,"800,9","-0,5","3511,8","8,8",10:58:27,,,
|
||||||
|
34,"800,7","-0,7","3510,2","8,8",10:58:43,,,
|
||||||
|
35,"797,3","-4,1","3480,3","8,7",10:59:02,,,
|
||||||
|
36,"796,2","-5,1","3471,0","8,7",11:14:15,,,
|
||||||
|
37,"809,7","8,4","3589,9","8,9",11:14:37,,,
|
||||||
|
38,"802,9","1,6","3529,6","8,8",11:15:02,,,
|
||||||
|
39,"810,3","8,9","3594,7","8,9",11:15:27,,,
|
||||||
|
40,"804,2","2,9","3541,5","8,8",11:15:57,,,
|
||||||
|
41,"807,1","5,8","3567,2","8,8",11:28:00,,,
|
||||||
|
42,"805,4","4,1","3552,0","8,8",11:28:20,,,
|
||||||
|
43,"802,0","0,7","3522,2","8,8",11:28:50,,,
|
||||||
|
44,"804,6","3,3","3544,6","8,8",11:29:11,,,
|
||||||
|
45,"807,6","6,3","3571,4","8,8",11:29:32,,,
|
||||||
|
46,"807,3","6,0","3568,5","8,8",11:30:05,,,
|
||||||
|
47,"795,8","-5,5","3467,8","8,7",11:30:19,,,
|
||||||
|
48,"798,2","-3,1","3488,9","8,7",11:30:36,,,
|
||||||
|
49,"801,9","0,5","3520,6","8,8",11:30:49,,,
|
||||||
|
50,"801,2","-0,2","3514,5","8,8",11:31:10,,,
|
||||||
|
-,,,,,,
|
||||||
|
VITESSE MOYENNE,"801,3",,,,,
|
||||||
|
FACTEUR DE PUISSANCE MOYEN,"8,8",,,,,
|
||||||
|
ÉCART-TYPE,"4,1",,,,,
|
||||||
|
SPREAD,"20,8",,,,,
|
||||||
|
Poids du projectile (GRAINS),"169,0",,,,,
|
||||||
|
ÉNERGIE CINÉTIQUE MOY.,"3516,1",,,,,
|
||||||
|
Note sur la session,,,,,,
|
||||||
|
-,,,,,,
|
||||||
|
Date,"MARS 16, 2026 09:13",,,,,
|
||||||
|
Tous les tirs inclus dans les calculs,,,,,,
|
||||||
|
80
templates/base.html
Normal file
80
templates/base.html
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Ballistic Analyzer</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
background: #f4f5f7;
|
||||||
|
color: #222;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2rem 2.5rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.8rem; margin-bottom: 1.5rem; color: #1a1a2e; }
|
||||||
|
h2 { font-size: 1.3rem; margin: 2rem 0 0.75rem; color: #1a1a2e; border-bottom: 2px solid #e0e0e0; padding-bottom: 0.3rem; }
|
||||||
|
h3 { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; color: #333; }
|
||||||
|
a { color: #1f77b4; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
.error {
|
||||||
|
background: #fff0f0;
|
||||||
|
border-left: 4px solid #e74c3c;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #f0f4ff;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr:hover td { background: #fafbff; }
|
||||||
|
.group-section {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.group-meta {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.chart-img {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 860px;
|
||||||
|
display: block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
78
templates/results.html
Normal file
78
templates/results.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
|
||||||
|
<h1 style="margin:0;">Analysis Results</h1>
|
||||||
|
<div style="display:flex;gap:0.75rem;align-items:center;">
|
||||||
|
<a href="/">← Upload another file</a>
|
||||||
|
<a href="data:application/pdf;base64,{{ pdf_b64 }}"
|
||||||
|
download="ballistic_report.pdf"
|
||||||
|
style="background:#1f77b4;color:#fff;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;text-decoration:none;">
|
||||||
|
Download PDF report
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Overall Statistics</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Metric</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Total shots</td><td>{{ overall.count }}</td></tr>
|
||||||
|
<tr><td>Min speed</td><td>{{ "%.4f"|format(overall.min_speed) }}</td></tr>
|
||||||
|
<tr><td>Max speed</td><td>{{ "%.4f"|format(overall.max_speed) }}</td></tr>
|
||||||
|
<tr><td>Mean speed</td><td>{{ "%.4f"|format(overall.mean_speed) }}</td></tr>
|
||||||
|
<tr>
|
||||||
|
<td>Std dev (speed)</td>
|
||||||
|
<td>
|
||||||
|
{% if overall.std_speed is not none %}
|
||||||
|
{{ "%.4f"|format(overall.std_speed) }}
|
||||||
|
{% else %}
|
||||||
|
–
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<img class="chart-img" src="data:image/png;base64,{{ overview_chart }}"
|
||||||
|
alt="Avg speed and std dev per group" style="max-width:600px;margin:1rem 0 1.5rem;">
|
||||||
|
|
||||||
|
<h2>Groups — {{ groups_display|length }} group(s) detected</h2>
|
||||||
|
|
||||||
|
{% for stat, chart_b64 in groups_display %}
|
||||||
|
<div class="group-section">
|
||||||
|
<h3>Group {{ stat.group_index }}</h3>
|
||||||
|
<div class="group-meta">
|
||||||
|
{{ stat.time_start }} – {{ stat.time_end }} | {{ stat.count }} shot(s)
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Metric</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Min speed</td><td>{{ "%.4f"|format(stat.min_speed) }}</td></tr>
|
||||||
|
<tr><td>Max speed</td><td>{{ "%.4f"|format(stat.max_speed) }}</td></tr>
|
||||||
|
<tr><td>Mean speed</td><td>{{ "%.4f"|format(stat.mean_speed) }}</td></tr>
|
||||||
|
<tr>
|
||||||
|
<td>Std dev (speed)</td>
|
||||||
|
<td>
|
||||||
|
{% if stat.std_speed is not none %}
|
||||||
|
{{ "%.4f"|format(stat.std_speed) }}
|
||||||
|
{% else %}
|
||||||
|
–
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<img class="chart-img" src="data:image/png;base64,{{ chart_b64 }}" alt="Speed chart for group {{ stat.group_index }}">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
30
templates/upload.html
Normal file
30
templates/upload.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Ballistic Analyzer</h1>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p style="margin-bottom:1.5rem; color:#555;">
|
||||||
|
Upload a CSV file to analyse shot groups. The file must contain the following columns:
|
||||||
|
<strong>index</strong>, <strong>speed</strong>, <strong>standard deviation</strong>,
|
||||||
|
<strong>energy</strong>, <strong>power factor</strong>, <strong>time of the day</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="POST" action="/analyze" enctype="multipart/form-data" style="display:flex;gap:1rem;align-items:center;flex-wrap:wrap;">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="csv_file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
required
|
||||||
|
style="border:1px solid #ccc;border-radius:4px;padding:0.5rem 0.75rem;background:#fafafa;font-size:0.95rem;"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style="background:#1f77b4;color:#fff;border:none;border-radius:4px;padding:0.55rem 1.4rem;font-size:0.95rem;cursor:pointer;"
|
||||||
|
>
|
||||||
|
Analyze
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user