pycoustic 0.1.7__tar.gz → 0.1.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pycoustic
3
- Version: 0.1.7
3
+ Version: 0.1.8
4
4
  Summary:
5
5
  Author: thumpercastle
6
6
  Author-email: tony.ryb@gmail.com
@@ -6,6 +6,8 @@ import datetime as dt
6
6
 
7
7
  class Log:
8
8
  def __init__(self, path=""):
9
+ #TODO C:\Users\tonyr\PycharmProjects\pycoustic\.venv2\Lib\site-packages\pycoustic\log.py:15: UserWarning:
10
+ #Parsing dates in %Y/%m/%d %H:%M format when dayfirst=True was specified. Pass `dayfirst=False` or specify a format to silence this warning.
9
11
  """
10
12
  The Log class is used to store the measured noise data from one data logger.
11
13
  The data must be entered in a .csv file with headings in the specific format "Leq A", "L90 125" etc.
@@ -341,6 +343,9 @@ class Log:
341
343
  self._master = self._append_night_idx(data=self._master)
342
344
  self._antilogs = self._append_night_idx(data=self._antilogs)
343
345
 
346
+ #C:\Users\tonyr\PycharmProjects\pycoustic\.venv2\Lib\site-packages\pycoustic\log.py:339: PerformanceWarning:
347
+ #dropping on a non-lexsorted multi-index without a level parameter may impact performance.
348
+
344
349
  def get_period_times(self):
345
350
  """
346
351
  :return: the tuples of period start times.
@@ -0,0 +1,277 @@
1
+ import os
2
+ import tempfile
3
+ from typing import List, Dict
4
+
5
+ import pandas as pd
6
+ import plotly.graph_objects as go
7
+ import streamlit as st
8
+
9
+ from log import *
10
+ from survey import *
11
+
12
+ # Streamlit app config
13
+ st.set_page_config(page_title="Pycoustic Acoustic Survey Explorer", layout="wide")
14
+
15
+ # Graph colour palette config
16
+ COLOURS = {
17
+ "Leq A": "#9e9e9e", # light grey
18
+ "L90 A": "#4d4d4d", # dark grey
19
+ "Lmax A": "#fc2c2c", # red
20
+ }
21
+ # Graph template config
22
+ TEMPLATE = "plotly"
23
+
24
+ if "apply_agg" not in st.session_state:
25
+ st.session_state["apply_agg"] = False
26
+ if "period_last" not in st.session_state:
27
+ st.session_state["period_last"] = ""
28
+
29
+ with st.sidebar:
30
+ # File Upload in expander container
31
+ with st.expander("File Upload", expanded=True):
32
+ files = st.file_uploader(
33
+ "Select one or more CSV files",
34
+ type="csv",
35
+ accept_multiple_files=True,
36
+ )
37
+ if not files:
38
+ st.stop()
39
+ # Integration period entry in expander container
40
+ with st.expander("Integration Period", expanded=True):
41
+ int_period = st.number_input(
42
+ "Insert new integration period (must be larger than data)",
43
+ step=1,
44
+ value=15,
45
+ )
46
+ period_select = st.selectbox(
47
+ "Please select time period",
48
+ ("second(s)", "minute(s)", "hour(s)"),
49
+ index=1,
50
+ )
51
+
52
+ # Build the period string
53
+ suffix_map = {"second(s)": "s", "minute(s)": "min", "hour(s)": "h"}
54
+ period = f"{int_period}{suffix_map.get(period_select, '')}"
55
+
56
+ # If the period changed since last time, reset the "apply_agg" flag
57
+ if st.session_state["period_last"] != period:
58
+ st.session_state["apply_agg"] = False
59
+ st.session_state["period_last"] = period
60
+
61
+ # Button to trigger aggregation for ALL positions
62
+ apply_agg_btn = st.button("Apply Integration Period")
63
+ if apply_agg_btn:
64
+ st.session_state["apply_agg"] = True
65
+
66
+ # Main Window / Data Load
67
+ with st.spinner("Processing Data...", show_time=True):
68
+ # Load each uploaded CSV into a pycoustic Log
69
+ logs: Dict[str, Log] = {}
70
+ for upload_file in files:
71
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp:
72
+ tmp.write(upload_file.getbuffer())
73
+ path = tmp.name
74
+ try:
75
+ logs[upload_file.name] = Log(path)
76
+ except Exception as err:
77
+ st.error(f"Failed to load `{upload_file.name}` into Pycoustic: {err}")
78
+ finally:
79
+ os.unlink(path)
80
+
81
+ # Build Survey and pull summary + spectra
82
+ summary_df = leq_spec_df = lmax_spec_df = None
83
+ summary_error = ""
84
+ if logs:
85
+ try:
86
+ survey = Survey()
87
+ if callable(getattr(survey, "add_log", None)):
88
+ for name, lg in logs.items():
89
+ survey.add_log(lg, name=name)
90
+ elif hasattr(survey, "_logs"):
91
+ survey._logs = logs
92
+
93
+ summary_df = survey.resi_summary()
94
+ leq_spec_df = getattr(survey, "typical_leq_spectra", lambda: None)()
95
+ lmax_spec_df = getattr(survey, "lmax_spectra", lambda: None)()
96
+ except Exception as err:
97
+ summary_error = str(err)
98
+ else:
99
+ summary_error = "No valid logs loaded."
100
+
101
+ # Helper list of “position” names (i.e. filenames)
102
+ pos_list = list(logs.keys())
103
+
104
+ # Helper: turn a “spectra” DataFrame into a long‐format table for plotting
105
+ def spectra_to_rows(df: pd.DataFrame, pos_names: List[str]) -> pd.DataFrame | None:
106
+ if df is None:
107
+ return None
108
+ if not isinstance(df.columns, pd.MultiIndex):
109
+ tidy = df.reset_index().rename(columns={df.index.name or "index": "Period"})
110
+ if "Position" not in tidy.columns:
111
+ tidy.insert(0, "Position", pos_names[0] if pos_names else "Pos1")
112
+ return tidy
113
+
114
+ # If there is a MultiIndex
115
+ bands = [band for _, band in df.columns][: len({band for _, band in df.columns})]
116
+ set_len = len(bands)
117
+ blocks = []
118
+ for i, pos in enumerate(pos_names):
119
+ start, end = i * set_len, (i + 1) * set_len
120
+ if end > df.shape[1]:
121
+ break
122
+ sub = df.iloc[:, start:end].copy()
123
+ sub.columns = [str(b) for b in bands]
124
+ sub = sub.reset_index().rename(columns={df.index.names[-1] or "index": "Period"})
125
+ if "Position" not in sub.columns:
126
+ sub.insert(0, "Position", pos)
127
+ blocks.append(sub)
128
+ return pd.concat(blocks, ignore_index=True)
129
+
130
+ #Create tabs
131
+ ui_tabs = st.tabs(["Summary"] + pos_list)
132
+
133
+ #Summary tab
134
+ with ui_tabs[0]:
135
+ st.subheader("Broadband Summary")
136
+ if summary_df is not None:
137
+ st.dataframe(summary_df)
138
+ else:
139
+ st.warning(f"Summary unavailable: {summary_error}")
140
+
141
+ # Plot “Typical Leq Spectra” and “Lmax Spectra”, if available
142
+ for title, df_data in (
143
+ ("Typical Leq Spectra", leq_spec_df),
144
+ ("Lmax Spectra", lmax_spec_df),
145
+ ):
146
+ tidy = spectra_to_rows(df_data, pos_list)
147
+ if tidy is None:
148
+ continue
149
+
150
+ freq_cols = [c for c in tidy.columns if c not in ("Position", "Period", "A")]
151
+ if freq_cols:
152
+ fig = go.Figure()
153
+ for pos in pos_list:
154
+ subset = tidy[tidy["Position"] == pos]
155
+ for _, row in subset.iterrows():
156
+ period_label = row["Period"]
157
+ # Cast to string so .lower() is safe
158
+ period_label_str = str(period_label)
159
+ mode = (
160
+ "lines+markers"
161
+ if period_label_str.lower().startswith("day")
162
+ else "lines"
163
+ )
164
+ label = (
165
+ f"{pos} {period_label_str}"
166
+ if len(pos_list) > 1
167
+ else period_label_str
168
+ )
169
+ fig.add_trace(
170
+ go.Scatter(
171
+ x=freq_cols,
172
+ y=row[freq_cols],
173
+ mode=mode,
174
+ name=label,
175
+ )
176
+ )
177
+ fig.update_layout(
178
+ template=TEMPLATE,
179
+ title=f"{title} - Day & Night",
180
+ xaxis_title="Octave band (Hz)",
181
+ yaxis_title="dB",
182
+ )
183
+ st.plotly_chart(fig, use_container_width=True)
184
+ else:
185
+ st.warning(f"No frequency columns found for `{title}`.")
186
+
187
+ # Position‐Specific Tabs
188
+ for tab, uf in zip(ui_tabs[1:], files):
189
+ with tab:
190
+ log = logs.get(uf.name)
191
+ if log is None:
192
+ st.error(f"Log for `{uf.name}` not found.")
193
+ continue
194
+
195
+ # Decide whether to show raw or aggregated data
196
+ if st.session_state["apply_agg"]:
197
+ # 1) Re-aggregate / resample using the chosen period
198
+ try:
199
+ df_used = log.as_interval(t=period)
200
+ df_used = df_used.reset_index().rename(
201
+ columns={df_used.index.name or "index": "Timestamp"}
202
+ )
203
+ subheader = "Integrated Survey Data"
204
+ except Exception as e:
205
+ st.error(f"Failed to apply integration period for `{uf.name}`: {e}")
206
+ continue
207
+ else:
208
+ # 2) Show the raw data (from log._master) if available
209
+ try:
210
+ raw_master = log._master # original DataFrame, indexed by Timestamp
211
+ df_used = raw_master.reset_index().rename(columns={"Time": "Timestamp"})
212
+ subheader = "Raw Survey Data"
213
+ except Exception as e:
214
+ st.error(f"Failed to load raw data for `{uf.name}`: {e}")
215
+ continue
216
+
217
+ # Prepare a flattened‐column header copy JUST FOR PLOTTING
218
+ df_plot = df_used.copy()
219
+ if isinstance(df_plot.columns, pd.MultiIndex):
220
+ flattened_cols = []
221
+ for lvl0, lvl1 in df_plot.columns:
222
+ lvl0_str = str(lvl0)
223
+ lvl1_str = str(lvl1) if lvl1 is not None else ""
224
+ flattened_cols.append(f"{lvl0_str} {lvl1_str}".strip())
225
+ df_plot.columns = flattened_cols
226
+
227
+ # Time‐history Graph (Leq A, L90 A, Lmax A) using df_plot
228
+ required_cols = {"Leq A", "L90 A", "Lmax A"}
229
+ if required_cols.issubset(set(df_plot.columns)):
230
+ fig = go.Figure()
231
+ fig.add_trace(
232
+ go.Scatter(
233
+ x=df_plot["Timestamp"],
234
+ y=df_plot["Leq A"],
235
+ name="Leq A",
236
+ mode="lines",
237
+ line=dict(color=COLOURS["Leq A"], width=1),
238
+ )
239
+ )
240
+ fig.add_trace(
241
+ go.Scatter(
242
+ x=df_plot["Timestamp"],
243
+ y=df_plot["L90 A"],
244
+ name="L90 A",
245
+ mode="lines",
246
+ line=dict(color=COLOURS["L90 A"], width=1),
247
+ )
248
+ )
249
+ fig.add_trace(
250
+ go.Scatter(
251
+ x=df_plot["Timestamp"],
252
+ y=df_plot["Lmax A"],
253
+ name="Lmax A",
254
+ mode="markers",
255
+ marker=dict(color=COLOURS["Lmax A"], size=3),
256
+ )
257
+ )
258
+ fig.update_layout(
259
+ template=TEMPLATE,
260
+ margin=dict(l=0, r=0, t=0, b=0),
261
+ xaxis=dict(
262
+ title="Time & Date (hh:mm & dd/mm/yyyy)",
263
+ type="date",
264
+ tickformat="%H:%M<br>%d/%m/%Y",
265
+ tickangle=0,
266
+ ),
267
+ yaxis_title="Measured Sound Pressure Level dB(A)",
268
+ legend=dict(orientation="h", yanchor="top", y=-0.25, xanchor="left", x=0),
269
+ height=600,
270
+ )
271
+ st.plotly_chart(fig, use_container_width=True)
272
+ else:
273
+ st.warning(f"Required columns {required_cols} missing in {subheader}.")
274
+
275
+ # --- Finally, display the TABLE with MultiIndex intact ---
276
+ st.subheader(subheader)
277
+ st.dataframe(df_used, hide_index=True)
@@ -0,0 +1,522 @@
1
+ # Python 3.12
2
+ # Streamlit app entrypoint for PyCoustic-like workflow
3
+
4
+ from __future__ import annotations
5
+
6
+ import io
7
+ import re
8
+ from typing import Dict, Iterable, List, Optional, Tuple
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ import plotly.graph_objects as go
13
+ import streamlit as st
14
+
15
+
16
+ # -----------------------------
17
+ # Plotting configuration
18
+ # -----------------------------
19
+ COLOURS: List[str] = [
20
+ "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
21
+ "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf",
22
+ ]
23
+ TEMPLATE: str = "plotly_white"
24
+
25
+
26
+ # -----------------------------
27
+ # Helpers
28
+ # -----------------------------
29
+ def _try_read_table(upload) -> pd.DataFrame:
30
+ # Try CSV first, then Excel
31
+ name = getattr(upload, "name", "uploaded")
32
+ data = upload.read() if hasattr(upload, "read") else upload.getvalue()
33
+ # ensure the buffer can be reused for Excel attempt
34
+ buf = io.BytesIO(data)
35
+
36
+ # CSV attempt
37
+ try:
38
+ df_csv = pd.read_csv(io.BytesIO(data))
39
+ if not df_csv.empty:
40
+ df_csv.attrs["__source_name__"] = name
41
+ return df_csv
42
+ except Exception:
43
+ pass
44
+
45
+ # Excel attempt
46
+ buf.seek(0)
47
+ try:
48
+ df_xls = pd.read_excel(buf)
49
+ if not df_xls.empty:
50
+ df_xls.attrs["__source_name__"] = name
51
+ return df_xls
52
+ except Exception:
53
+ pass
54
+
55
+ raise ValueError(f"Could not parse file: {name}")
56
+
57
+
58
+ def _flatten_columns(df: pd.DataFrame) -> pd.DataFrame:
59
+ # Flatten MultiIndex columns if any
60
+ if isinstance(df.columns, pd.MultiIndex):
61
+ flat = [" | ".join([str(x) for x in tup if x is not None]) for tup in df.columns]
62
+ df = df.copy()
63
+ df.columns = flat
64
+ return df
65
+
66
+
67
+ def _maybe_parse_datetime(df: pd.DataFrame) -> pd.DataFrame:
68
+ # Heuristic: try common datetime column names and parse them
69
+ dt_candidates = ["Datetime", "DateTime", "Timestamp", "Time", "Date", "Date_Time", "datetime", "time", "timestamp"]
70
+ for col in df.columns:
71
+ if col in dt_candidates or re.search(r"time|date|stamp", str(col), re.IGNORECASE):
72
+ try:
73
+ parsed = pd.to_datetime(df[col], errors="raise", utc=False, infer_datetime_format=True)
74
+ out = df.copy()
75
+ out[col] = parsed
76
+ return out
77
+ except Exception:
78
+ continue
79
+ return df
80
+
81
+
82
+ def _detect_position_col(df: pd.DataFrame) -> Optional[str]:
83
+ candidates = ["Position", "Pos", "Mic", "Channel", "Location", "Site"]
84
+ for c in candidates:
85
+ if c in df.columns:
86
+ return c
87
+ # also try case-insensitive exact matches
88
+ lower_map = {str(c).lower(): c for c in df.columns}
89
+ for c in candidates:
90
+ if c.lower() in lower_map:
91
+ return lower_map[c.lower()]
92
+ return None
93
+
94
+
95
+ def _metric_patterns() -> Dict[str, re.Pattern]:
96
+ # Detect wide spectral columns, e.g., "Leq_31.5", "Lmax_1000", "Leq 4k", "Lmax 1 kHz", "63 Hz"
97
+ # Strategy:
98
+ # - Either prefixed by a metric (Leq/Lmax) and then frequency
99
+ # - Or pure frequency with "Hz" and a separate metric column naming is handled by selection
100
+ def freq_part():
101
+ # numbers like 31.5, 1000, 1k, 2 kHz, etc.
102
+ return r"(?P<freq>(\d+(\.\d+)?)(\s*k(hz)?)?)"
103
+
104
+ # metric-first naming: "Leq_31.5", "Lmax 1000", "Leq-1k", "Leq 1 kHz"
105
+ metric_first = rf"^(?P<metric>Leq|Lmax)[\s_\-]*{freq_part()}(hz)?$"
106
+ # freq-first naming: "31.5", "63 Hz", "1k", with an optional suffix metric after a sep: "63Hz_Leq"
107
+ freq_first = rf"^{freq_part()}(hz)?[\s_\-]*(?P<metric>Leq|Lmax)?$"
108
+ return {
109
+ "metric_first": re.compile(metric_first, re.IGNORECASE),
110
+ "freq_first": re.compile(freq_first, re.IGNORECASE),
111
+ }
112
+
113
+
114
+ def _parse_freq_to_hz(freq_str: str) -> Optional[float]:
115
+ if freq_str is None:
116
+ return None
117
+ s = str(freq_str).strip().lower().replace(" ", "")
118
+ s = s.replace("khz", "k").replace("hz", "")
119
+ # handle "1k" or "1.0k"
120
+ m = re.match(r"^(\d+(\.\d+)?)k$", s)
121
+ if m:
122
+ return float(m.group(1)) * 1000.0
123
+ try:
124
+ return float(s)
125
+ except Exception:
126
+ return None
127
+
128
+
129
+ def spectra_to_rows(df: pd.DataFrame) -> pd.DataFrame:
130
+ """
131
+ Convert wide spectral columns into a tidy long form:
132
+ Columns like "Leq_31.5", "Lmax 63", "63 Hz Leq" -> rows with Frequency (Hz), Metric, Value.
133
+ Non-spectral columns are carried along.
134
+ """
135
+ df = _flatten_columns(df)
136
+ patterns = _metric_patterns()
137
+
138
+ spectral_cols: List[Tuple[str, str, float]] = [] # (original_col, metric, freq_hz)
139
+ for col in df.columns:
140
+ col_str = str(col)
141
+ matched = False
142
+
143
+ # metric first
144
+ m1 = patterns["metric_first"].match(col_str)
145
+ if m1:
146
+ metric = m1.group("metric").upper()
147
+ freq_hz = _parse_freq_to_hz(m1.group("freq"))
148
+ if metric in ("LEQ", "LMAX") and freq_hz is not None:
149
+ spectral_cols.append((col, metric, freq_hz))
150
+ matched = True
151
+
152
+ if matched:
153
+ continue
154
+
155
+ # frequency first
156
+ m2 = patterns["freq_first"].match(col_str)
157
+ if m2:
158
+ metric = m2.group("metric")
159
+ metric = metric.upper() if metric else None
160
+ freq_hz = _parse_freq_to_hz(m2.group("freq"))
161
+ if freq_hz is not None:
162
+ # If metric is not embedded, we will treat it as "LEQ" by default for plotting,
163
+ # but also keep the column name when we pivot.
164
+ spectral_cols.append((col, metric or "LEQ", freq_hz))
165
+
166
+ if not spectral_cols:
167
+ return pd.DataFrame(columns=["Frequency_Hz", "Metric", "Value"])
168
+
169
+ # Build tidy rows
170
+ id_cols = [c for c in df.columns if c not in [c0 for (c0, _, _) in spectral_cols]]
171
+ tidies: List[pd.DataFrame] = []
172
+ for (col, metric, f_hz) in spectral_cols:
173
+ block = pd.DataFrame(
174
+ {
175
+ "Frequency_Hz": f_hz,
176
+ "Metric": metric,
177
+ "Value": df[col].astype("float64").values,
178
+ }
179
+ )
180
+ # Attach IDs if present
181
+ if id_cols:
182
+ block = pd.concat([df[id_cols].reset_index(drop=True), block], axis=1)
183
+ tidies.append(block)
184
+
185
+ tidy = pd.concat(tidies, axis=0, ignore_index=True)
186
+ # Sort by frequency numeric
187
+ tidy = tidy.sort_values(["Metric", "Frequency_Hz"]).reset_index(drop=True)
188
+ return tidy
189
+
190
+
191
+ def _resample_if_possible(df: pd.DataFrame, how: str) -> pd.DataFrame:
192
+ """
193
+ how: '', '1min', '5min', '1H', '1D'
194
+ """
195
+ if not how:
196
+ return df
197
+
198
+ # find a datetime column
199
+ dt_col = None
200
+ for c in df.columns:
201
+ if pd.api.types.is_datetime64_any_dtype(df[c]):
202
+ dt_col = c
203
+ break
204
+
205
+ if dt_col is None:
206
+ return df # nothing to resample on
207
+
208
+ df_sorted = df.sort_values(dt_col)
209
+ df_sorted = df_sorted.set_index(dt_col)
210
+
211
+ # numeric only for resample
212
+ numeric_cols = df_sorted.select_dtypes(include=["number"]).columns
213
+ if len(numeric_cols) == 0:
214
+ return df
215
+
216
+ grouped = df_sorted[numeric_cols].resample(how).mean().reset_index()
217
+ # put back other columns in a sensible way (drop or take first)
218
+ return grouped
219
+
220
+
221
+ def _build_summary(df: pd.DataFrame, group_cols: List[str]) -> pd.DataFrame:
222
+ if not group_cols:
223
+ # simple numeric summary
224
+ numeric_cols = df.select_dtypes(include=["number"]).columns
225
+ if len(numeric_cols) == 0:
226
+ return pd.DataFrame()
227
+ s = df[numeric_cols].agg(["count", "mean", "std", "min", "max"]).T.reset_index()
228
+ s.columns = ["Metric"] + list(s.columns[1:])
229
+ return s
230
+
231
+ # groupby summary on numeric columns
232
+ numeric_cols = df.select_dtypes(include=["number"]).columns
233
+ if len(numeric_cols) == 0:
234
+ return pd.DataFrame()
235
+
236
+ g = df.groupby(group_cols, dropna=False)[numeric_cols].agg(["count", "mean", "std", "min", "max"])
237
+ g = g.reset_index()
238
+
239
+ # flatten resulting MultiIndex columns
240
+ out_cols = []
241
+ for tup in g.columns:
242
+ if isinstance(tup, tuple):
243
+ lvl0, lvl1 = tup
244
+ if lvl1 == "":
245
+ out_cols.append(str(lvl0))
246
+ elif lvl0 in group_cols:
247
+ out_cols.append(str(lvl0))
248
+ else:
249
+ out_cols.append(f"{lvl0}__{lvl1}")
250
+ else:
251
+ out_cols.append(str(tup))
252
+ g.columns = out_cols
253
+ return g
254
+
255
+
256
+ def _guess_resample_options(df: pd.DataFrame) -> List[Tuple[str, str]]:
257
+ # Label, pandas rule
258
+ has_dt = any(pd.api.types.is_datetime64_any_dtype(df[c]) for c in df.columns)
259
+ if not has_dt:
260
+ return [("None", "")]
261
+ return [
262
+ ("None", ""),
263
+ ("1 minute", "1min"),
264
+ ("5 minutes", "5min"),
265
+ ("15 minutes", "15min"),
266
+ ("Hourly", "1H"),
267
+ ("Daily", "1D"),
268
+ ]
269
+
270
+
271
+ def _plot_spectra(tidy_spec: pd.DataFrame, color_by: Optional[str]) -> go.Figure:
272
+ fig = go.Figure()
273
+ if tidy_spec.empty:
274
+ fig.update_layout(template=TEMPLATE)
275
+ return fig
276
+
277
+ # X is frequency Hz (log10)
278
+ x = tidy_spec["Frequency_Hz"].to_numpy(dtype=float)
279
+
280
+ # determine trace grouping
281
+ if color_by and color_by in tidy_spec.columns:
282
+ groups = list(tidy_spec[color_by].astype(str).unique())
283
+ for i, key in enumerate(groups):
284
+ sub = tidy_spec[tidy_spec[color_by].astype(str) == str(key)]
285
+ # Keep metric separated as line style if available
286
+ if "Metric" in sub.columns and sub["Metric"].nunique() > 1:
287
+ for metric in sorted(sub["Metric"].unique()):
288
+ subm = sub[sub["Metric"] == metric]
289
+ fig.add_trace(
290
+ go.Scatter(
291
+ x=subm["Frequency_Hz"],
292
+ y=subm["Value"],
293
+ mode="lines+markers",
294
+ name=f"{key} – {metric}",
295
+ line=dict(color=COLOURS[i % len(COLOURS)], dash="solid" if metric == "LEQ" else "dash"),
296
+ marker=dict(size=6),
297
+ )
298
+ )
299
+ else:
300
+ fig.add_trace(
301
+ go.Scatter(
302
+ x=sub["Frequency_Hz"],
303
+ y=sub["Value"],
304
+ mode="lines+markers",
305
+ name=str(key),
306
+ line=dict(color=COLOURS[i % len(COLOURS)]),
307
+ marker=dict(size=6),
308
+ )
309
+ )
310
+ else:
311
+ # single trace per metric
312
+ if "Metric" in tidy_spec.columns:
313
+ for i, metric in enumerate(sorted(tidy_spec["Metric"].unique())):
314
+ sub = tidy_spec[tidy_spec["Metric"] == metric]
315
+ fig.add_trace(
316
+ go.Scatter(
317
+ x=sub["Frequency_Hz"],
318
+ y=sub["Value"],
319
+ mode="lines+markers",
320
+ name=str(metric),
321
+ line=dict(color=COLOURS[i % len(COLOURS)]),
322
+ marker=dict(size=6),
323
+ )
324
+ )
325
+ else:
326
+ fig.add_trace(
327
+ go.Scatter(
328
+ x=x,
329
+ y=tidy_spec["Value"],
330
+ mode="lines+markers",
331
+ name="Spectrum",
332
+ line=dict(color=COLOURS[0]),
333
+ marker=dict(size=6),
334
+ )
335
+ )
336
+
337
+ fig.update_layout(
338
+ template=TEMPLATE,
339
+ xaxis=dict(
340
+ type="log",
341
+ title="Frequency (Hz)",
342
+ tickvals=[31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000],
343
+ ticktext=["31.5", "63", "125", "250", "500", "1k", "2k", "4k", "8k"],
344
+ gridcolor="rgba(0,0,0,0.1)",
345
+ ),
346
+ yaxis=dict(title="Level (dB)", gridcolor="rgba(0,0,0,0.1)"),
347
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
348
+ margin=dict(l=50, r=30, t=40, b=50),
349
+ )
350
+ return fig
351
+
352
+
353
+ def _download_csv_button(label: str, df: pd.DataFrame, key: str):
354
+ csv = df.to_csv(index=False).encode("utf-8")
355
+ st.download_button(
356
+ label=label,
357
+ data=csv,
358
+ file_name=f"{key}.csv",
359
+ mime="text/csv",
360
+ use_container_width=True,
361
+ )
362
+
363
+
364
+ # -----------------------------
365
+ # UI
366
+ # -----------------------------
367
+ def main():
368
+ st.set_page_config(page_title="PyCoustic – Streamlit", layout="wide")
369
+
370
+ st.title("PyCoustic – Streamlit App")
371
+ st.caption("Upload measurement logs, explore summaries and spectra, and export results.")
372
+
373
+ with st.sidebar:
374
+ st.header("Inputs")
375
+ uploads = st.file_uploader(
376
+ "Upload one or more files (CSV or Excel)",
377
+ type=["csv", "txt", "xlsx", "xls"],
378
+ accept_multiple_files=True,
379
+ )
380
+
381
+ st.markdown("---")
382
+ st.subheader("Options")
383
+
384
+ # These options approximate a typical workflow
385
+ resample_label = "Resample period"
386
+ resample_options: List[Tuple[str, str]] = [("None", "")]
387
+ # temporarily show None; we'll refine after reading a file
388
+
389
+ # placeholder UI to avoid reflow
390
+ resample_choice_label = st.selectbox(resample_label, [x[0] for x in resample_options], index=0, key="resample_placeholder")
391
+
392
+ group_hint = st.text_input("Optional Group Column (e.g., Position/Location)", value="")
393
+
394
+ st.markdown("---")
395
+ st.subheader("Spectra")
396
+ colour_by = st.text_input("Colour lines by column (e.g., Position or source)", value="source")
397
+ show_markers = st.checkbox("Show markers", value=True)
398
+
399
+ st.markdown("---")
400
+ st.subheader("Display")
401
+ show_raw_preview = st.checkbox("Show raw preview table", value=False)
402
+
403
+ if not uploads:
404
+ st.info("Upload files to begin.")
405
+ return
406
+
407
+ # Read and combine
408
+ logs: List[str] = []
409
+ frames: List[pd.DataFrame] = []
410
+ for uf in uploads:
411
+ try:
412
+ df = _try_read_table(uf)
413
+ df = _flatten_columns(df)
414
+ df = _maybe_parse_datetime(df)
415
+ df["source"] = getattr(uf, "name", df.attrs.get("__source_name__", "uploaded"))
416
+ frames.append(df)
417
+ logs.append(f"Loaded: {df['source'].iloc[0]} (rows={len(df)}, cols={len(df.columns)})")
418
+ except Exception as e:
419
+ logs.append(f"Error reading {getattr(uf, 'name', '?')}: {e}")
420
+
421
+ if not frames:
422
+ st.error("No readable files.")
423
+ st.text_area("Logs", value="\n".join(logs), height=160)
424
+ return
425
+
426
+ raw_master = pd.concat(frames, axis=0, ignore_index=True)
427
+ # Now that we have data, rebuild resample options
428
+ resample_options = _guess_resample_options(raw_master)
429
+ resample_choice_label = st.sidebar.selectbox(
430
+ "Resample period",
431
+ [x[0] for x in resample_options],
432
+ index=0,
433
+ key="resample_choice",
434
+ )
435
+ resample_rule = dict(resample_options)[resample_choice_label]
436
+
437
+ # Optional resampling
438
+ df_used = _resample_if_possible(raw_master, resample_rule) if resample_rule else raw_master
439
+
440
+ # Try to determine a reasonable group column
441
+ detected_group_col = _detect_position_col(df_used)
442
+ group_col = (st.sidebar.text_input("Detected Group Column", value=detected_group_col or "") or "").strip()
443
+ if not group_col and group_hint.strip():
444
+ group_col = group_hint.strip()
445
+ if group_col and group_col not in df_used.columns:
446
+ st.warning(f"Group column '{group_col}' not found. It will be ignored.")
447
+ group_col = ""
448
+
449
+ # Build spectra in tidy form
450
+ tidy_spec = spectra_to_rows(df_used)
451
+
452
+ tabs = st.tabs(["Summary", "Spectra", "Raw Data", "Logs"])
453
+
454
+ # Summary tab
455
+ with tabs[0]:
456
+ st.subheader("Summary")
457
+ group_cols: List[str] = []
458
+ if group_col:
459
+ group_cols.append(group_col)
460
+ if "source" in df_used.columns:
461
+ group_cols.append("source")
462
+
463
+ summary_df = _build_summary(df_used, group_cols)
464
+ if summary_df.empty:
465
+ st.info("No numeric data to summarize.")
466
+ else:
467
+ st.dataframe(summary_df, use_container_width=True, hide_index=True)
468
+ _download_csv_button("Download summary CSV", summary_df, "summary")
469
+
470
+ # Spectra tab
471
+ with tabs[1]:
472
+ st.subheader("Spectra")
473
+
474
+ # Allow user to filter a group (optional)
475
+ filters_cols = []
476
+ if group_col:
477
+ filters_cols.append(group_col)
478
+ if "source" in tidy_spec.columns:
479
+ filters_cols.append("source")
480
+
481
+ sub = tidy_spec.copy()
482
+ # Dynamic filters
483
+ if not sub.empty and filters_cols:
484
+ cols = st.columns(len(filters_cols))
485
+ for i, colname in enumerate(filters_cols):
486
+ with cols[i]:
487
+ uniq = sorted([str(x) for x in sub[colname].dropna().unique()])
488
+ if len(uniq) <= 1:
489
+ continue
490
+ selected = st.multiselect(f"Filter {colname}", options=uniq, default=uniq)
491
+ sub = sub[sub[colname].astype(str).isin(selected)]
492
+
493
+ # Plot
494
+ if sub.empty:
495
+ st.info("No spectral data detected in the uploaded files.")
496
+ else:
497
+ fig = _plot_spectra(sub, color_by=(colour_by if colour_by in sub.columns else None))
498
+ if not show_markers:
499
+ for tr in fig.data:
500
+ tr.mode = "lines"
501
+ st.plotly_chart(fig, use_container_width=True)
502
+
503
+ # Download tidy spectra
504
+ _download_csv_button("Download tidy spectra CSV", sub, "spectra_tidy")
505
+
506
+ # Raw Data tab
507
+ with tabs[2]:
508
+ st.subheader("Raw Data")
509
+ if show_raw_preview:
510
+ st.dataframe(raw_master, use_container_width=True, hide_index=True)
511
+ else:
512
+ st.caption("Enable 'Show raw preview table' in the sidebar to render the full table.")
513
+ _download_csv_button("Download combined raw CSV", raw_master, "raw_combined")
514
+
515
+ # Logs tab
516
+ with tabs[3]:
517
+ st.subheader("Logs")
518
+ st.text_area("Ingestion log", value="\n".join(logs), height=240, label_visibility="collapsed")
519
+
520
+
521
+ if __name__ == "__main__":
522
+ main()
@@ -10,7 +10,7 @@ pd.set_option('display.max_rows', None)
10
10
 
11
11
  #survey.leq_spectra() bug
12
12
  #TODO: C:\Users\tonyr\PycharmProjects\pycoustic\.venv1\Lib\site-packages\pycoustic\survey.py:287: FutureWarning: The behavior of pd.concat with len(keys) != len(objs) is deprecated. In a future version this will raise instead of truncating to the smaller of the two sequences combi = pd.concat(all_pos, axis=1, keys=["UA1", "UA2"])
13
-
13
+ #TODO: Survey should make a deep copy of Log objects. Otherwise setting time periods messes it up for other instances.
14
14
 
15
15
  class Survey:
16
16
  """
@@ -200,6 +200,7 @@ class Survey:
200
200
  return combi
201
201
 
202
202
  def counts(self, cols=None, day_t="60min", evening_t="60min", night_t="15min"):
203
+ #TODO Need to order rows and rename from 'date'
203
204
  """
204
205
  Returns counts for each time period. For example, this can return the number of L90 occurrences at each decibel
205
206
  level for daytime and night-time periods.
@@ -327,3 +328,91 @@ class Survey:
327
328
  raise ValueError("No weather history available. Use Survey.weather() first.")
328
329
  return pd.DataFrame([self._weatherhist.min(), self._weatherhist.max(), self._weatherhist.mean()],
329
330
  index=["Min", "Max", "Mean"]).drop(columns=["dt"]).round(decimals=1)
331
+
332
+
333
+ # TODO: Fix this bug in weatherhist
334
+ # survey.weather(api_key=r"eef3f749e018627b70c2ead1475a1a32", postcode="HA8")
335
+ # dt temp pressure humidity clouds wind_speed wind_deg \
336
+ # 0 2025-09-03 08:59:00 17.52 998 97 75 6.69 210
337
+ # 1 2025-09-03 14:59:00 19.85 997 84 40 9.26 220
338
+ # 2 2025-09-03 20:59:00 16.27 1003.0 90.0 20.0 4.63 240.0
339
+ # 3 2025-09-04 02:59:00 14.59 1005.0 91.0 99.0 3.09 230.0
340
+ # 4 2025-09-04 08:59:00 15.08 1004 93 40 4.12 200
341
+ # 5 2025-09-04 14:59:00 18.73 1007 63 40 8.75 260
342
+ # 6 2025-09-04 20:59:00 15.64 1013.0 76.0 0.0 3.6 270.0
343
+ # 7 2025-09-05 02:59:00 11.42 1016.0 94.0 0.0 3.09 260.0
344
+ # 8 2025-09-05 08:59:00 14.12 1020.0 89.0 20.0 3.09 270.0
345
+ # 9 2025-09-05 14:59:00 22.16 1021.0 50.0 0.0 4.12 280.0
346
+ # 10 2025-09-05 20:59:00 17.38 1023.0 75.0 75.0 3.09 220.0
347
+ # 11 2025-09-06 02:59:00 14.37 1022.0 83.0 99.0 1.78 187.0
348
+ # 12 2025-09-06 08:59:00 16.44 1020.0 73.0 100.0 3.48 138.0
349
+ # 13 2025-09-06 14:59:00 23.21 1037.0 50.0 0.0 7.72 160.0
350
+ # 14 2025-09-06 20:59:00 18.5 1035.0 75.0 93.0 3.6 120.0
351
+ # 15 2025-09-07 02:59:00 16.06 1031.0 77.0 84.0 3.09 120.0
352
+ # 16 2025-09-07 08:59:00 18.78 1029.0 77.0 0.0 4.63 110.0
353
+ # 17 2025-09-07 14:59:00 23.82 1027.0 67.0 75.0 8.75 200.0
354
+ # 18 2025-09-07 20:59:00 19.38 1031.0 76.0 72.0 4.63 200.0
355
+ # 19 2025-09-08 02:59:00 14.49 1034.0 91.0 4.0 1.54 190.0
356
+ # 20 2025-09-08 08:59:00 14.84 1037.0 85.0 20.0 4.12 240.0
357
+ # rain wind_gust uvi
358
+ # 0 {'1h': 0.25} NaN NaN
359
+ # 1 {'1h': 1.27} 14.92 NaN
360
+ # 2 NaN NaN NaN
361
+ # 3 NaN NaN NaN
362
+ # 4 {'1h': 1.27} NaN NaN
363
+ # 5 {'3h': 0.13} NaN NaN
364
+ # 6 NaN NaN NaN
365
+ # 7 NaN NaN NaN
366
+ # 8 NaN NaN NaN
367
+ # 9 NaN NaN NaN
368
+ # 10 NaN NaN NaN
369
+ # 11 NaN 3.31 0.0
370
+ # 12 NaN 7.4 0.86
371
+ # 13 NaN NaN 2.96
372
+ # 14 NaN NaN 0.0
373
+ # 15 NaN NaN 0.0
374
+ # 16 NaN NaN 1.1
375
+ # 17 NaN NaN 2.24
376
+ # 18 NaN NaN 0.0
377
+ # 19 NaN NaN 0.0
378
+ # 20 NaN NaN 1.12
379
+ # survey.weather_summary()
380
+ # Traceback (most recent call last):
381
+ # File "<input>", line 1, in <module>
382
+ # File "C:\Users\tonyr\PycharmProjects\pycoustic\.venv2\Lib\site-packages\pycoustic\survey.py", line 328, in weather_summary
383
+ # return pd.DataFrame([self._weatherhist.min(), self._weatherhist.max(), self._weatherhist.mean()],
384
+ # ^^^^^^^^^^^^^^^^^^^^^^^
385
+ # File "C:\Users\tonyr\PycharmProjects\pycoustic\.venv2\Lib\site-packages\pandas\core\frame.py", line 11643, in min
386
+ # result = super().min(axis, skipna, numeric_only, **kwargs)
387
+ # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
388
+ # File "C:\Users\tonyr\PycharmProjects\pycoustic\.venv2\Lib\site-packages\pandas\core\generic.py", line 12388, in min
389
+ # return self._stat_function(
390
+ # ^^^^^^^^^^^^^^^^^^^^
391
+ # File "C:\Users\tonyr\PycharmProjects\pycoustic\.venv2\Lib\site-packages\pandas\core\generic.py", line 12377, in _stat_function
392
+ # return self._reduce(
393
+ # ^^^^^^^^^^^^^
394
+ # File "C:\Users\tonyr\PycharmProjects\pycoustic\.venv2\Lib\site-packages\pandas\core\frame.py", line 11562, in _reduce
395
+ # res = df._mgr.reduce(blk_func)
396
+ # ^^^^^^^^^^^^^^^^^^^^^^^^
397
+ # File "C:\Users\tonyr\PycharmProjects\pycoustic\.venv2\Lib\site-packages\pandas\core\internals\managers.py", line 1500, in reduce
398
+ # nbs = blk.reduce(func)
399
+ # ^^^^^^^^^^^^^^^^
400
+ # File "C:\Users\tonyr\PycharmProjects\pycoustic\.venv2\Lib\site-packages\pandas\core\internals\blocks.py", line 404, in reduce
401
+ # result = func(self.values)
402
+ # ^^^^^^^^^^^^^^^^^
403
+ # File "C:\Users\tonyr\PycharmProjects\pycoustic\.venv2\Lib\site-packages\pandas\core\frame.py", line 11481, in blk_func
404
+ # return op(values, axis=axis, skipna=skipna, **kwds)
405
+ # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
406
+ # File "C:\Users\tonyr\PycharmProjects\pycoustic\.venv2\Lib\site-packages\pandas\core\nanops.py", line 147, in f
407
+ # result = alt(values, axis=axis, skipna=skipna, **kwds)
408
+ # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
409
+ # File "C:\Users\tonyr\PycharmProjects\pycoustic\.venv2\Lib\site-packages\pandas\core\nanops.py", line 404, in new_func
410
+ # result = func(values, axis=axis, skipna=skipna, mask=mask, **kwargs)
411
+ # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
412
+ # File "C:\Users\tonyr\PycharmProjects\pycoustic\.venv2\Lib\site-packages\pandas\core\nanops.py", line 1098, in reduction
413
+ # result = getattr(values, meth)(axis)
414
+ # ^^^^^^^^^^^^^^^^^^^^^^^^^^^
415
+ # File "C:\Users\tonyr\PycharmProjects\pycoustic\.venv2\Lib\site-packages\numpy\_core\_methods.py", line 48, in _amin
416
+ # return umr_minimum(a, axis, None, out, keepdims, initial, where)
417
+ # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
418
+ # TypeError: '<=' not supported between instances of 'dict' and 'dict'
@@ -93,3 +93,4 @@ class WeatherHistory:
93
93
 
94
94
  def get_weather_history(self):
95
95
  return self._hist
96
+
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pycoustic"
3
- version = "0.1.7"
3
+ version = "0.1.8"
4
4
  description = ""
5
5
  authors = ["thumpercastle <tony.ryb@gmail.com>"]
6
6
  readme = "README.md"
File without changes
File without changes