well-log-toolkit 0.1.146__tar.gz → 0.1.148__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.
Files changed (20) hide show
  1. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/PKG-INFO +1 -1
  2. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/pyproject.toml +1 -1
  3. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/well_log_toolkit/manager.py +494 -0
  4. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/well_log_toolkit/property.py +4 -0
  5. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/well_log_toolkit.egg-info/PKG-INFO +1 -1
  6. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/README.md +0 -0
  7. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/setup.cfg +0 -0
  8. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/well_log_toolkit/__init__.py +0 -0
  9. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/well_log_toolkit/exceptions.py +0 -0
  10. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/well_log_toolkit/las_file.py +0 -0
  11. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/well_log_toolkit/operations.py +0 -0
  12. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/well_log_toolkit/regression.py +0 -0
  13. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/well_log_toolkit/statistics.py +0 -0
  14. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/well_log_toolkit/utils.py +0 -0
  15. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/well_log_toolkit/visualization.py +0 -0
  16. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/well_log_toolkit/well.py +0 -0
  17. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/well_log_toolkit.egg-info/SOURCES.txt +0 -0
  18. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/well_log_toolkit.egg-info/dependency_links.txt +0 -0
  19. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/well_log_toolkit.egg-info/requires.txt +0 -0
  20. {well_log_toolkit-0.1.146 → well_log_toolkit-0.1.148}/well_log_toolkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: well-log-toolkit
3
- Version: 0.1.146
3
+ Version: 0.1.148
4
4
  Summary: Fast LAS file processing with lazy loading and filtering for well log analysis
5
5
  Author-email: Kristian dF Kollsgård <kkollsg@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "well-log-toolkit"
7
- version = "0.1.146"
7
+ version = "0.1.148"
8
8
  description = "Fast LAS file processing with lazy loading and filtering for well log analysis"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1618,6 +1618,420 @@ class _ManagerPropertyProxy:
1618
1618
  )
1619
1619
 
1620
1620
 
1621
+ class _ManagerMultiPropertyProxy:
1622
+ """
1623
+ Proxy for computing statistics across multiple properties on all wells.
1624
+
1625
+ Supports filter(), filter_intervals(), and sums_avg() methods.
1626
+ Multi-property results nest property-specific stats under property names
1627
+ while keeping common stats (depth_range, samples, thickness, etc.) at
1628
+ the group level.
1629
+ """
1630
+
1631
+ # Stats that are specific to each property (nested under property name)
1632
+ PROPERTY_STATS = {'mean', 'median', 'mode', 'sum', 'std_dev', 'percentile', 'range'}
1633
+
1634
+ # Stats that are common across properties (stay at group level)
1635
+ COMMON_STATS = {'depth_range', 'samples', 'thickness', 'gross_thickness', 'thickness_fraction', 'calculation'}
1636
+
1637
+ def __init__(
1638
+ self,
1639
+ manager: 'WellDataManager',
1640
+ property_names: list[str],
1641
+ filters: Optional[list[tuple]] = None,
1642
+ custom_intervals: Optional[dict] = None
1643
+ ):
1644
+ self._manager = manager
1645
+ self._property_names = property_names
1646
+ self._filters = filters or []
1647
+ self._custom_intervals = custom_intervals
1648
+
1649
+ def __getattr__(self, name: str) -> '_ManagerMultiPropertyProxy':
1650
+ """
1651
+ Attribute access as shorthand for filter().
1652
+
1653
+ Allows: manager.properties(['A', 'B']).Facies.sums_avg()
1654
+ Same as: manager.properties(['A', 'B']).filter('Facies').sums_avg()
1655
+ """
1656
+ # Avoid recursion for private attributes
1657
+ if name.startswith('_'):
1658
+ raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
1659
+ # Treat as filter
1660
+ return self.filter(name)
1661
+
1662
+ def filter(
1663
+ self,
1664
+ property_name: str,
1665
+ insert_boundaries: Optional[bool] = None
1666
+ ) -> '_ManagerMultiPropertyProxy':
1667
+ """
1668
+ Add a filter (discrete property) to group statistics by.
1669
+
1670
+ Parameters
1671
+ ----------
1672
+ property_name : str
1673
+ Name of discrete property to group by
1674
+ insert_boundaries : bool, optional
1675
+ Whether to insert boundary values at filter transitions
1676
+
1677
+ Returns
1678
+ -------
1679
+ _ManagerMultiPropertyProxy
1680
+ New proxy with filter added
1681
+ """
1682
+ new_filters = self._filters + [(property_name, insert_boundaries)]
1683
+ return _ManagerMultiPropertyProxy(
1684
+ self._manager, self._property_names, new_filters, self._custom_intervals
1685
+ )
1686
+
1687
+ def filter_intervals(
1688
+ self,
1689
+ intervals: Union[str, list, dict],
1690
+ name: str = "Custom_Intervals",
1691
+ insert_boundaries: Optional[bool] = None,
1692
+ save: Optional[str] = None
1693
+ ) -> '_ManagerMultiPropertyProxy':
1694
+ """
1695
+ Filter by custom depth intervals.
1696
+
1697
+ Parameters
1698
+ ----------
1699
+ intervals : str, list, or dict
1700
+ - str: Name of saved intervals to retrieve from each well
1701
+ - list: List of interval dicts [{"name": "Zone_A", "top": 2500, "base": 2700}, ...]
1702
+ - dict: Well-specific intervals {"well_name": [...], ...}
1703
+ name : str, default "Custom_Intervals"
1704
+ Name for the interval filter in results
1705
+ insert_boundaries : bool, optional
1706
+ Whether to insert boundary values at interval edges
1707
+ save : str, optional
1708
+ If provided, save intervals to wells with this name
1709
+
1710
+ Returns
1711
+ -------
1712
+ _ManagerMultiPropertyProxy
1713
+ New proxy with custom intervals set
1714
+ """
1715
+ intervals_config = {
1716
+ 'intervals': intervals,
1717
+ 'name': name,
1718
+ 'insert_boundaries': insert_boundaries,
1719
+ 'save': save
1720
+ }
1721
+ return _ManagerMultiPropertyProxy(
1722
+ self._manager, self._property_names, self._filters, intervals_config
1723
+ )
1724
+
1725
+ def sums_avg(
1726
+ self,
1727
+ weighted: Optional[bool] = None,
1728
+ arithmetic: Optional[bool] = None,
1729
+ precision: int = 6
1730
+ ) -> dict:
1731
+ """
1732
+ Compute statistics for multiple properties across all wells.
1733
+
1734
+ Multi-property results nest property-specific stats (mean, median, etc.)
1735
+ under each property name, while common stats (depth_range, samples,
1736
+ thickness, etc.) remain at the group level.
1737
+
1738
+ Parameters
1739
+ ----------
1740
+ weighted : bool, optional
1741
+ Include depth-weighted statistics.
1742
+ Default: True for continuous/discrete, False for sampled
1743
+ arithmetic : bool, optional
1744
+ Include arithmetic (unweighted) statistics.
1745
+ Default: False for continuous/discrete, True for sampled
1746
+ precision : int, default 6
1747
+ Number of decimal places for rounding numeric results
1748
+
1749
+ Returns
1750
+ -------
1751
+ dict
1752
+ Nested dictionary with structure:
1753
+ {
1754
+ "well_name": {
1755
+ "interval_name": { # if using filter_intervals
1756
+ "filter_value": {
1757
+ "PropertyA": {"mean": ..., "median": ..., ...},
1758
+ "PropertyB": {"mean": ..., "median": ..., ...},
1759
+ "depth_range": {...},
1760
+ "samples": ...,
1761
+ "thickness": ...,
1762
+ ...
1763
+ }
1764
+ }
1765
+ }
1766
+ }
1767
+
1768
+ Examples
1769
+ --------
1770
+ >>> manager.properties(['PHIE', 'PERM']).filter('Facies').sums_avg()
1771
+ >>> # Returns stats for both properties grouped by facies
1772
+
1773
+ >>> manager.properties(['PHIE', 'PERM']).filter_intervals("Zones").sums_avg()
1774
+ >>> # Returns stats for both properties grouped by custom intervals
1775
+
1776
+ >>> # No filters - compute stats for full well
1777
+ >>> manager.properties(['PHIE', 'PERM']).sums_avg()
1778
+ """
1779
+ result = {}
1780
+
1781
+ for well_name, well in self._manager._wells.items():
1782
+ well_result = self._compute_sums_avg_for_well(
1783
+ well, weighted, arithmetic, precision
1784
+ )
1785
+ if well_result is not None:
1786
+ result[well_name] = well_result
1787
+
1788
+ return _sanitize_for_json(result)
1789
+
1790
+ def _compute_sums_avg_for_well(
1791
+ self,
1792
+ well,
1793
+ weighted: Optional[bool],
1794
+ arithmetic: Optional[bool],
1795
+ precision: int
1796
+ ):
1797
+ """
1798
+ Compute multi-property sums_avg for a single well.
1799
+ """
1800
+ # Check if this well has the required saved intervals (if using saved name)
1801
+ if self._custom_intervals:
1802
+ intervals = self._custom_intervals.get('intervals')
1803
+ if isinstance(intervals, str):
1804
+ # Saved filter name - check if this well has it
1805
+ if intervals not in well._saved_filter_intervals:
1806
+ return None # Skip wells that don't have this saved filter
1807
+ elif isinstance(intervals, dict):
1808
+ # Well-specific intervals - check if this well is in the dict
1809
+ if well.name not in intervals and well.sanitized_name not in intervals:
1810
+ return None # Skip wells not in the dict
1811
+
1812
+ # Collect results for each property
1813
+ property_results = {}
1814
+
1815
+ for prop_name in self._property_names:
1816
+ try:
1817
+ prop = well.get_property(prop_name)
1818
+
1819
+ # Apply filter_intervals if set
1820
+ if self._custom_intervals:
1821
+ prop = self._apply_filter_intervals(prop, well)
1822
+ if prop is None:
1823
+ continue # Skip this property if intervals can't be applied
1824
+
1825
+ # Apply all filters
1826
+ for filter_name, insert_boundaries in self._filters:
1827
+ if insert_boundaries is not None:
1828
+ prop = prop.filter(filter_name, insert_boundaries=insert_boundaries)
1829
+ else:
1830
+ prop = prop.filter(filter_name)
1831
+
1832
+ # Compute sums_avg
1833
+ result = prop.sums_avg(
1834
+ weighted=weighted,
1835
+ arithmetic=arithmetic,
1836
+ precision=precision
1837
+ )
1838
+ property_results[prop_name] = result
1839
+
1840
+ except (PropertyNotFoundError, PropertyTypeError, AttributeError, KeyError):
1841
+ # Property doesn't exist in this well or filter error, skip it
1842
+ pass
1843
+
1844
+ if not property_results:
1845
+ return None
1846
+
1847
+ # If no filters/intervals, return simple merged result (no grouping)
1848
+ if not self._filters and not self._custom_intervals:
1849
+ return self._merge_flat_results(property_results)
1850
+
1851
+ # Merge results: nest property-specific stats, keep common stats at group level
1852
+ return self._merge_property_results(property_results)
1853
+
1854
+ def _apply_filter_intervals(self, prop, well):
1855
+ """
1856
+ Apply filter_intervals to a property if custom_intervals is set.
1857
+
1858
+ Returns None if the well doesn't have the required saved intervals.
1859
+ """
1860
+ if not self._custom_intervals:
1861
+ return prop
1862
+
1863
+ intervals_config = self._custom_intervals
1864
+ intervals = intervals_config['intervals']
1865
+ name = intervals_config['name']
1866
+ insert_boundaries = intervals_config['insert_boundaries']
1867
+ save = intervals_config['save']
1868
+
1869
+ # Resolve intervals for this well
1870
+ if isinstance(intervals, str):
1871
+ # Saved filter name - check if this well has it
1872
+ if intervals not in well._saved_filter_intervals:
1873
+ return None # Skip wells that don't have this saved filter
1874
+ well_intervals = intervals
1875
+ elif isinstance(intervals, dict):
1876
+ # Well-specific intervals
1877
+ well_intervals = None
1878
+ if well.name in intervals:
1879
+ well_intervals = intervals[well.name]
1880
+ elif well.sanitized_name in intervals:
1881
+ well_intervals = intervals[well.sanitized_name]
1882
+ if well_intervals is None:
1883
+ return None # Skip wells not in the dict
1884
+ elif isinstance(intervals, list):
1885
+ # Direct list of intervals
1886
+ well_intervals = intervals
1887
+ else:
1888
+ return None
1889
+
1890
+ # Apply filter_intervals
1891
+ return prop.filter_intervals(
1892
+ well_intervals,
1893
+ name=name,
1894
+ insert_boundaries=insert_boundaries,
1895
+ save=save
1896
+ )
1897
+
1898
+ def _merge_flat_results(self, property_results: dict) -> dict:
1899
+ """
1900
+ Merge results when no filters are applied (flat structure).
1901
+
1902
+ Returns a single dict with property-specific stats nested under property
1903
+ names and common stats at the top level.
1904
+
1905
+ Parameters
1906
+ ----------
1907
+ property_results : dict
1908
+ {property_name: sums_avg_result}
1909
+
1910
+ Returns
1911
+ -------
1912
+ dict
1913
+ {
1914
+ "PropertyA": {"mean": ..., "median": ..., ...},
1915
+ "PropertyB": {"mean": ..., ...},
1916
+ "depth_range": {...},
1917
+ "samples": ...,
1918
+ ...
1919
+ }
1920
+ """
1921
+ if not property_results:
1922
+ return {}
1923
+
1924
+ result = {}
1925
+
1926
+ # Add property-specific stats for each property
1927
+ for prop_name, prop_result in property_results.items():
1928
+ if isinstance(prop_result, dict):
1929
+ # Extract property-specific stats
1930
+ prop_stats = {
1931
+ k: v for k, v in prop_result.items()
1932
+ if k in self.PROPERTY_STATS
1933
+ }
1934
+ if prop_stats:
1935
+ result[prop_name] = prop_stats
1936
+
1937
+ # Add common stats from first property
1938
+ first_result = next(iter(property_results.values()))
1939
+ if isinstance(first_result, dict):
1940
+ for k, v in first_result.items():
1941
+ if k in self.COMMON_STATS:
1942
+ result[k] = v
1943
+
1944
+ return result
1945
+
1946
+ def _merge_property_results(self, property_results: dict) -> dict:
1947
+ """
1948
+ Merge results from multiple properties.
1949
+
1950
+ Nests property-specific stats under property names while keeping
1951
+ common stats at the group level.
1952
+
1953
+ Parameters
1954
+ ----------
1955
+ property_results : dict
1956
+ {property_name: sums_avg_result}
1957
+
1958
+ Returns
1959
+ -------
1960
+ dict
1961
+ Merged result with structure:
1962
+ {
1963
+ "group_value": {
1964
+ "PropertyA": {"mean": ..., ...},
1965
+ "PropertyB": {"mean": ..., ...},
1966
+ "depth_range": {...},
1967
+ "samples": ...,
1968
+ ...
1969
+ }
1970
+ }
1971
+ """
1972
+ if not property_results:
1973
+ return {}
1974
+
1975
+ # Use first property result as the structure template
1976
+ first_prop = next(iter(property_results.keys()))
1977
+ first_result = property_results[first_prop]
1978
+
1979
+ return self._merge_recursive(property_results, first_result)
1980
+
1981
+ def _merge_recursive(self, property_results: dict, template: dict) -> dict:
1982
+ """
1983
+ Recursively merge property results following the template structure.
1984
+ """
1985
+ result = {}
1986
+
1987
+ for key, value in template.items():
1988
+ if isinstance(value, dict):
1989
+ # Check if this is a stats dict (has property-specific keys)
1990
+ if any(k in value for k in self.PROPERTY_STATS):
1991
+ # This is a leaf stats dict - merge property stats here
1992
+ merged = {}
1993
+
1994
+ # Add property-specific stats for each property
1995
+ for prop_name, prop_result in property_results.items():
1996
+ # Navigate to the same key in this property's result
1997
+ prop_value = self._get_nested_value(prop_result, key)
1998
+ if prop_value and isinstance(prop_value, dict):
1999
+ # Extract property-specific stats
2000
+ prop_stats = {
2001
+ k: v for k, v in prop_value.items()
2002
+ if k in self.PROPERTY_STATS
2003
+ }
2004
+ if prop_stats:
2005
+ merged[prop_name] = prop_stats
2006
+
2007
+ # Add common stats from the first property
2008
+ for k, v in value.items():
2009
+ if k in self.COMMON_STATS:
2010
+ merged[k] = v
2011
+
2012
+ result[key] = merged
2013
+ else:
2014
+ # This is an intermediate nesting level - recurse
2015
+ # Collect corresponding sub-dicts from all properties
2016
+ sub_property_results = {}
2017
+ for prop_name, prop_result in property_results.items():
2018
+ prop_value = self._get_nested_value(prop_result, key)
2019
+ if prop_value and isinstance(prop_value, dict):
2020
+ sub_property_results[prop_name] = prop_value
2021
+
2022
+ if sub_property_results:
2023
+ result[key] = self._merge_recursive(sub_property_results, value)
2024
+ else:
2025
+ # Non-dict value, just copy from template
2026
+ result[key] = value
2027
+
2028
+ return result
2029
+
2030
+ def _get_nested_value(self, d: dict, key: str):
2031
+ """Get value from dict, returning None if key doesn't exist."""
2032
+ return d.get(key) if isinstance(d, dict) else None
2033
+
2034
+
1621
2035
  class WellDataManager:
1622
2036
  """
1623
2037
  Global orchestrator for multi-well analysis.
@@ -1710,6 +2124,60 @@ class WellDataManager:
1710
2124
  # Return a proxy that can be used for operations across all wells
1711
2125
  return _ManagerPropertyProxy(self, name)
1712
2126
 
2127
+ def properties(self, property_names: list[str]) -> _ManagerMultiPropertyProxy:
2128
+ """
2129
+ Create a multi-property proxy for computing statistics across multiple properties.
2130
+
2131
+ This allows computing statistics for multiple properties at once, with
2132
+ property-specific stats (mean, median, etc.) nested under property names
2133
+ and common stats (depth_range, samples, thickness, etc.) at the group level.
2134
+
2135
+ Parameters
2136
+ ----------
2137
+ property_names : list[str]
2138
+ List of property names to include in statistics
2139
+
2140
+ Returns
2141
+ -------
2142
+ _ManagerMultiPropertyProxy
2143
+ Proxy that supports filter(), filter_intervals(), and sums_avg()
2144
+
2145
+ Examples
2146
+ --------
2147
+ >>> # Compute stats for multiple properties grouped by facies
2148
+ >>> manager.properties(['PHIE', 'PERM']).filter('Facies').sums_avg()
2149
+ >>> # Returns:
2150
+ >>> # {
2151
+ >>> # "well_A": {
2152
+ >>> # "Sand": {
2153
+ >>> # "PHIE": {"mean": 0.18, "median": 0.17, ...},
2154
+ >>> # "PERM": {"mean": 150, "median": 120, ...},
2155
+ >>> # "depth_range": {...},
2156
+ >>> # "samples": 387,
2157
+ >>> # "thickness": 29.4,
2158
+ >>> # ...
2159
+ >>> # }
2160
+ >>> # }
2161
+ >>> # }
2162
+
2163
+ >>> # With custom intervals
2164
+ >>> manager.properties(['PHIE', 'PERM']).filter('Facies').filter_intervals("Zones").sums_avg()
2165
+ >>> # Returns:
2166
+ >>> # {
2167
+ >>> # "well_A": {
2168
+ >>> # "Zone_1": {
2169
+ >>> # "Sand": {
2170
+ >>> # "PHIE": {"mean": 0.18, ...},
2171
+ >>> # "PERM": {"mean": 150, ...},
2172
+ >>> # "depth_range": {...},
2173
+ >>> # ...
2174
+ >>> # }
2175
+ >>> # }
2176
+ >>> # }
2177
+ >>> # }
2178
+ """
2179
+ return _ManagerMultiPropertyProxy(self, property_names)
2180
+
1713
2181
  def load_las(
1714
2182
  self,
1715
2183
  filepath: Union[str, Path, list[Union[str, Path]]],
@@ -2473,6 +2941,18 @@ class WellDataManager:
2473
2941
  # Delete sources marked for deletion
2474
2942
  well.delete_marked_sources(well_folder)
2475
2943
 
2944
+ # Save filter intervals if any exist
2945
+ if hasattr(well, '_saved_filter_intervals') and well._saved_filter_intervals:
2946
+ import json
2947
+ intervals_file = well_folder / "intervals.json"
2948
+ with open(intervals_file, 'w') as f:
2949
+ json.dump(well._saved_filter_intervals, f, indent=2)
2950
+ else:
2951
+ # Remove intervals file if no intervals (in case they were deleted)
2952
+ intervals_file = well_folder / "intervals.json"
2953
+ if intervals_file.exists():
2954
+ intervals_file.unlink()
2955
+
2476
2956
  # Save templates
2477
2957
  if self._templates:
2478
2958
  templates_folder = save_path / "templates"
@@ -2563,6 +3043,20 @@ class WellDataManager:
2563
3043
  for las_file in las_files:
2564
3044
  self.load_las(las_file, silent=True)
2565
3045
 
3046
+ # Load saved filter intervals if they exist
3047
+ intervals_file = well_folder / "intervals.json"
3048
+ if intervals_file.exists():
3049
+ import json
3050
+ try:
3051
+ with open(intervals_file, 'r') as f:
3052
+ saved_intervals = json.load(f)
3053
+ # Find the well for this folder and set its intervals
3054
+ well_key = well_folder.name # e.g., "well_35_9_16_A"
3055
+ if well_key in self._wells:
3056
+ self._wells[well_key]._saved_filter_intervals = saved_intervals
3057
+ except Exception as e:
3058
+ warnings.warn(f"Could not load intervals from {intervals_file}: {e}")
3059
+
2566
3060
  return self
2567
3061
 
2568
3062
  def add_well(self, well_name: str) -> Well:
@@ -1165,6 +1165,10 @@ class Property(PropertyOperationsMixin):
1165
1165
  new_prop._original_sample_count = len(self.depth)
1166
1166
  new_prop._boundary_samples_inserted = len(new_depth) - len(self.depth)
1167
1167
 
1168
+ # Preserve custom intervals if they exist (from filter_intervals)
1169
+ if hasattr(self, '_custom_intervals') and self._custom_intervals:
1170
+ new_prop._custom_intervals = self._custom_intervals
1171
+
1168
1172
  return new_prop
1169
1173
 
1170
1174
  def filter_intervals(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: well-log-toolkit
3
- Version: 0.1.146
3
+ Version: 0.1.148
4
4
  Summary: Fast LAS file processing with lazy loading and filtering for well log analysis
5
5
  Author-email: Kristian dF Kollsgård <kkollsg@gmail.com>
6
6
  License: MIT